39 | 自增主键为什么不是连续的?
在第 4 篇文章中,我们提到过自增主键,由于自增主键可以让主键索引尽量地保持递增顺序插入,避免了页分裂,因此索引更紧凑。
之前我见过有的业务设计依赖于自增主键的连续性,也就是说,这个设计假设自增主键是连续的。但实际上,这样的假设是错的,因为自增主键不能保证连续递增。
今天这篇文章,我们就来说说这个问题,看看什么情况下自增主键会出现 “空洞”?
为了便于说明,我们创建一个表 t,其中 id 是自增主键字段、c 是唯一索引。
1 | CREATE TABLE `t` ( |
自增值保存在哪儿?
在这个空表 t 里面执行 insert into t values(null, 1, 1); 插入一行数据,再执行 show create table 命令,就可以看到如下图所示的结果:
可以看到,表定义里面出现了一个 AUTO_INCREMENT=2,表示下一次插入数据时,如果需要自动生成自增值,会生成 id=2。
其实,这个输出结果容易引起这样的误解:自增值是保存在表结构定义里的。实际上,表的结构定义存放在后缀名为.frm 的文件中,但是并不会保存自增值。
不同的引擎对于自增值的保存策略不同。
- MyISAM 引擎的自增值保存在数据文件中。
- InnoDB 引擎的自增值,其实是保存在了内存里,并且到了 MySQL 8.0 版本后,才有了“自增值持久化” 的能力,也就是才实现了“如果发生重启,表的自增值可以恢复为 MySQL 重启前的值”,具体情况是:
- 在 MySQL 5.7 及之前的版本,自增值保存在内存里,并没有持久化。每次重启后,第一次打开表的时候,都会去找自增值的最大值 max(id),然后将 max(id)+1 作为这个表当前的自增值。
举例来说,如果一个表当前数据行里最大的 id 是 10,AUTO_INCREMENT=11。这时候,我们删除 id=10 的行,AUTO_INCREMENT 还是 11。但如果马上重启实例,重启后这个表的 AUTO_INCREMENT 就会变成 10。
也就是说,MySQL 重启可能会修改一个表的 AUTO_INCREMENT 的值。 - 在 MySQL 8.0 版本,将自增值的变更记录在了 redo log 中,重启的时候依靠 redo log 恢复重启之前的值。
- 在 MySQL 5.7 及之前的版本,自增值保存在内存里,并没有持久化。每次重启后,第一次打开表的时候,都会去找自增值的最大值 max(id),然后将 max(id)+1 作为这个表当前的自增值。
理解了 MySQL 对自增值的保存策略以后,我们再看看自增值修改机制。
如何查看当前表的下一个自增主键值是多少? Show create table查看表信息,里面有个AUTO_INCREMENT=某个值,这个值就是下一个自增主键的值。MySQL自增主键的存放策略根据存储引擎的不同而不同
自增值修改机制
在 MySQL 里面,如果字段 id 被定义为 AUTO_INCREMENT,在插入一行数据的时候,自增值的行为如下:
- 如果插入数据时 id 字段指定为 0、null 或未指定值,那么就把这个表当前的 AUTO_INCREMENT 值填到自增字段;
- 如果插入数据时 id 字段指定了具体的值,就直接使用语句里指定的值。
必须结合sql_mode中一个值NO_AUTO_VALUE_ON_ZERO来判断,如果sql_mode中的有该值,则插入数据id=0时,就是0,而不是auto_increment的值了。具体的NO_AUTO_VALUE_ON_ZERO值介绍见官方文档:https://dev.mysql.com/doc/refman/5.7/en/sql-mode.html
根据要插入的值和当前自增值的大小关系,自增值的变更结果也会有所不同。假设,某次要插入的值是 X,当前的自增值是 Y。
- 如果 X<Y,那么这个表的自增值不变;
- 如果 X≥Y,就需要把当前自增值修改为新的自增值。
新的自增值生成算法是:从 auto_increment_offset 开始,以 auto_increment_increment 为步长,持续叠加,直到找到第一个大于 X 的值,作为新的自增值。
其中,auto_increment_offset 和 auto_increment_increment 是两个系统参数,分别用来表示自增的初始值和步长,默认值都是 1。
备注:在一些场景下,使用的就不全是默认值。比如,双 M 的主备结构里要求双写的时候,我们就可能会设置成 auto_increment_increment=2,让一个库的自增 id 都是奇数,另一个库的自增 id 都是偶数,避免两个库生成的主键发生冲突。
当 auto_increment_offset 和 auto_increment_increment 都是 1 的时候,新的自增值生成逻辑很简单,就是:
- 如果准备插入的值 >= 当前自增值,新的自增值就是“准备插入的值 +1”;
- 否则,自增值不变。
这就引入了我们文章开头提到的问题,在这两个参数都设置为 1 的时候,自增主键 id 却不能保证是连续的,这是什么原因呢?
自增值的修改时机
要回答这个问题,我们就要看一下自增值的修改时机。
假设,表 t 里面已经有了 (1,1,1) 这条记录,这时我再执行一条插入数据命令:
1 | insert into t values(null, 1, 1); |
这个语句的执行流程就是:
- 执行器调用 InnoDB 引擎接口写入一行,传入的这一行的值是 (0,1,1);
- InnoDB 发现用户没有指定自增 id 的值,获取表 t 当前的自增值 2;
- 将传入的行的值改成 (2,1,1);
- 将表的自增值改成 3;
- 继续执行插入数据操作,由于已经存在 c=1 的记录,所以报 Duplicate key error,语句返回。
对应的执行流程图如下:
可以看到,这个表的自增值改成 3,是在真正执行插入数据的操作之前。这个语句真正执行的时候,因为碰到唯一键 c 冲突,所以 id=2 这一行并没有插入成功,但也没有将自增值再改回去。
所以,在这之后,再插入新的数据行时,拿到的自增 id 就是 3。也就是说,出现了自增主键不连续的情况。
如图 3 所示就是完整的演示结果。
可以看到,这个操作序列复现了一个自增主键 id 不连续的现场 (没有 id=2 的行)。可见,唯一键冲突是导致自增主键 id 不连续的第一种原因。
同样地,事务回滚也会产生类似的现象,这就是第二种原因。
下面这个语句序列就可以构造不连续的自增 id,你可以自己验证一下。
1 | insert into t values(null,1,1); |
你可能会问,为什么在出现唯一键冲突或者回滚的时候,MySQL 没有把表 t 的自增值改回去呢?如果把表 t 的当前自增值从 3 改回 2,再插入新数据的时候,不就可以生成 id=2 的一行数据了吗?
其实,MySQL 这么设计是为了提升性能。接下来,我就跟你分析一下这个设计思路,看看自增值为什么不能回退。
假设有两个并行执行的事务,在申请自增值的时候,为了避免两个事务申请到相同的自增 id,肯定要加锁,然后顺序申请。
- 假设事务 A 申请到了 id=2, 事务 B 申请到 id=3,那么这时候表 t 的自增值是 4,之后继续执行。
- 事务 B 正确提交了,但事务 A 出现了唯一键冲突。
- 如果允许事务 A 把自增 id 回退,也就是把表 t 的当前自增值改回 2,那么就会出现这样的情况:表里面已经有 id=3 的行,而当前的自增 id 值是 2。
- 接下来,继续执行的其他事务就会申请到 id=2,然后再申请到 id=3。这时,就会出现插入语句报错“主键冲突”。
而为了解决这个主键冲突,有两种方法:
- 每次申请 id 之前,先判断表里面是否已经存在这个 id。如果存在,就跳过这个 id。但是,这个方法的成本很高。因为,本来申请 id 是一个很快的操作,现在还要再去主键索引树上判断 id 是否存在。
- 把自增 id 的锁范围扩大,必须等到一个事务执行完成并提交,下一个事务才能再申请自增 id。这个方法的问题,就是锁的粒度太大,系统并发能力大大下降。
可见,这两个方法都会导致性能问题。造成这些麻烦的罪魁祸首,就是我们假设的这个“允许自增 id 回退”的前提导致的。
因此,InnoDB 放弃了这个设计,语句执行失败也不回退自增 id。也正是因为这样,所以才只保证了自增 id 是递增的,但不保证是连续的。
自增锁的优化
可以看到,自增 id 锁并不是一个事务锁,而是每次申请完就马上释放,以便允许别的事务再申请。其实,在 MySQL 5.1 版本之前,并不是这样的。
接下来,我会先给你介绍下自增锁设计的历史,这样有助于你分析接下来的一个问题。
在 MySQL 5.0 版本的时候,自增锁的范围是语句级别。也就是说,如果一个语句申请了一个表自增锁,这个锁会等语句执行结束以后才释放。显然,这样设计会影响并发度。
MySQL 5.1.22 版本引入了一个新策略,新增参数 innodb_autoinc_lock_mode,默认值是 1。
- 这个参数的值被设置为 0 时,表示采用之前 MySQL 5.0 版本的策略,即语句执行结束后才释放锁;
- 这个参数的值被设置为 1 时:
- 普通 insert 语句,自增锁在申请之后就马上释放;
- 类似 insert … select 这样的批量插入数据的语句,自增锁还是要等语句结束后才被释放;
- 这个参数的值被设置为 2 时,所有的申请自增主键的动作都是申请后就释放锁。
你一定有两个疑问:为什么默认设置下,insert … select 要使用语句级的锁?为什么这个参数的默认值不是 2?
答案是,这么设计还是为了数据的一致性。
我们一起来看一下这个场景:
在这个例子里,我往表 t1 中插入了 4 行数据,然后创建了一个相同结构的表 t2,然后两个 session 同时执行向表 t2 中插入数据的操作。
你可以设想一下,如果 session B 是申请了自增值以后马上就释放自增锁,那么就可能出现这样的情况:
- session B 先插入了两个记录,(1,1,1)、(2,2,2);
- 然后,session A 来申请自增 id 得到 id=3,插入了(3,5,5);
- 之后,session B 继续执行,插入两条记录 (4,3,3)、 (5,4,4)。
你可能会说,这也没关系吧,毕竟 session B 的语义本身就没有要求表 t2 的所有行的数据都跟 session A 相同。
是的,从数据逻辑上看是对的。但是,如果我们现在的 binlog_format=statement,你可以设想下,binlog 会怎么记录呢?
由于两个 session 是同时执行插入数据命令的,所以 binlog 里面对表 t2 的更新日志只有两种情况:要么先记 session A 的,要么先记 session B 的。
但不论是哪一种,这个 binlog 拿去从库执行,或者用来恢复临时实例,备库和临时实例里面,session B 这个语句执行出来,生成的结果里面,id 都是连续的。这时,这个库就发生了数据不一致。
你可以分析一下,出现这个问题的原因是什么?
其实,这是因为原库 session B 的 insert 语句,生成的 id 不连续。这个不连续的 id,用 statement 格式的 binlog 来串行执行,是执行不出来的。
而要解决这个问题,有两种思路:
- 一种思路是,让原库的批量插入数据语句,固定生成连续的 id 值。所以,自增锁直到语句执行结束才释放,就是为了达到这个目的。
- 另一种思路是,在 binlog 里面把插入数据的操作都如实记录进来,到备库执行的时候,不再依赖于自增主键去生成。这种情况,其实就是 innodb_autoinc_lock_mode 设置为 2,同时 binlog_format 设置为 row。
因此,在生产上,尤其是有 insert … select 这种批量插入数据的场景时,从并发插入数据性能的角度考虑,我建议你这样设置:innodb_autoinc_lock_mode=2 ,并且 binlog_format=row. 这样做,既能提升并发性,又不会出现数据一致性问题。【MySQL 8.0 的默认设置】
需要注意的是,我这里说的批量插入数据,包含的语句类型是 insert … select、replace … select 和 load data 语句。
但是,在普通的 insert 语句里面包含多个 value 值的情况下,即使 innodb_autoinc_lock_mode 设置为 1,也不会等语句执行完成才释放锁。因为这类语句在申请自增 id 的时候,是可以精确计算出需要多少个 id 的,然后一次性申请,申请完成后锁就可以释放了。
也就是说,批量插入数据的语句,之所以需要这么设置,是因为“不知道要预先申请多少个 id”。
既然预先不知道要申请多少个自增 id,那么一种直接的想法就是需要一个时申请一个。但如果一个 select … insert 语句要插入 10 万行数据,按照这个逻辑的话就要申请 10 万次。显然,这种申请自增 id 的策略,在大批量插入数据的情况下,不但速度慢,还会影响并发插入的性能。
因此,对于批量插入数据的语句,MySQL 有一个批量申请自增 id 的策略:
- 语句执行过程中,第一次申请自增 id,会分配 1 个;
- 1 个用完以后,这个语句第二次申请自增 id,会分配 2 个;
- 2 个用完以后,还是这个语句,第三次申请自增 id,会分配 4 个;
- 依此类推,同一个语句去申请自增 id,每次申请到的自增 id 个数都是上一次的两倍。
举个例子,我们一起看看下面的这个语句序列:
1 | insert into t values(null, 1,1); |
insert…select,实际上往表 t2 中插入了 4 行数据。但是,这四行数据是分三次申请的自增 id,第一次申请到了 id=1,第二次被分配了 id=2 和 id=3, 第三次被分配到 id=4 到 id=7。
由于这条语句实际只用上了 4 个 id,所以 id=5 到 id=7 就被浪费掉了。之后,再执行 insert into t2 values(null, 5,5),实际上插入的数据就是(8,5,5)。
这是主键 id 出现自增 id 不连续的第三种原因。
问题
在最后一个例子中,执行 insert into t2(c,d) select c,d from t; 这个语句的时候,如果隔离级别是可重复读(repeatable read),binlog_format=statement。这个语句会对表 t 的所有记录和间隙加锁。
你觉得为什么需要这么做呢?
(即防止A、B同时执行,但insert语句和binlog的写入顺序无关,可能导致主备不一致)
如果在 insert … select 执行期间有其他线程操作原表,会导致逻辑错误。其实,这是不会的,如果不加锁,就是快照读。
一条语句执行期间,它的一致性视图是不会修改的,所以即使有其他事务修改了原表的数据,也不会影响这条语句看到的数据。
其他问题
[!question]
(1)问一下为什么一张表上面只能有一个自增的字段? (这个大概能从文章中分析出来,因为autoincrement是定义在表结构中,如果有多个的话实现自增的时候逻辑太复杂了)
(2)为什么自增的字段上面必须要有索引?
作者回复: 1. 是的 2
. 我觉得最初的一个原因是,由于以前(8.0版本前)自增主键值是不持久化的,只放在内存里面。每次重启后,重新打开表时,需要计算“自增字段里面的最大值”,然后加1,作为当前的autoincrement的值。
如果没有索引,算这个值就要做全表扫描,性能可能很差,影响访问表的速度。
好问题。不过这个只是我个人猜测,也可能还有别的原因。😆
[!question]
老师您好,我有一个时序问题,想请教一下。
从这篇文章的介绍来看,获取自增id和写binlog是有先后顺序的。
那么在binlog为statement的情况下。 语句A先获取id=1,然后B获取id=2,接着B提交,写binlog,再A写binlog。
这个时候如果binlog重放,是不是会发生B的id为1,而A的id为2的不一致的情况?
作者回复: 好问题,不会
因为binlog在记录这种带自增值的语句之前,会在前面多一句,用于指定“接下来这个语句要需要的 自增ID值是多少”,而这个值,是在主库上这一行插入成功后对应的自增值,所以是一致的
在binlog_format=STATEMENT 时,对于”simple insert” 语句binlog中会记录该语句插入的自增i值。如下:SET INSERT_ID=4/!/;。对于”Bulk insert”binlog中会记录该语句用到的第一个自增值
[!note]
在8.0.3版本后,innodb_autoinc_lock_mode默认值已是2,在binlog_format默认值为row的前提下,想来也是为了增加并发。
https://dev.mysql.com/doc/refman/8.0/en/innodb-parameters.html#sysvar_innodb_autoinc_lock_mode
40 | insert语句的锁为什么这么多?
在上一篇文章中,我提到 MySQL 对自增主键锁做了优化,尽量在申请到自增 id 以后,就释放自增锁。
因此,insert 语句是一个很轻量的操作。不过,这个结论对于“普通的 insert 语句”才有效。也就是说,还有些 insert 语句是属于“特殊情况”的,在执行过程中需要给其他资源加锁,或者无法在申请到自增 id 以后就立马释放自增锁。
那么,今天这篇文章,我们就一起来聊聊这个话题。
insert … select 语句
我们先从昨天的问题说起吧。表 t 和 t2 的表结构、初始化数据语句如下,今天的例子我们还是针对这两个表展开。
1 | CREATE TABLE `t` ( |
现在,我们一起来看看为什么在可重复读隔离级别下,binlog_format=statement 时执行:
这类问题的关键就是bin log的写入机制和mysql语句的执行机制是不一样的,所以会产生冲突。在大多数场景下语句先后的执行逻辑是不能变换的,在主从,备份,恢复时会出现严重的问题
1 | insert into t2(c,d) select c,d from t; |
这个语句时,需要对表 t 的所有行和间隙加锁呢?
其实,这个问题我们需要考虑的还是日志和数据的一致性。我们看下这个执行序列:
实际的执行效果是,如果 session B 先执行,由于这个语句对表 t 主键索引加了 (-∞,1] 这个 next-key lock,会在语句执行完成后,才允许 session A 的 insert 语句执行。
但如果没有锁的话,就可能出现 session B 的 insert 语句先执行,但是后写入 binlog 的情况。于是,在 binlog_format=statement 的情况下,binlog 里面就记录了这样的语句序列:
1 | insert into t values(-1,-1,-1); |
这个语句到了备库执行,就会把 id=-1 这一行也写到表 t2 中,出现主备不一致。
insert … select 是很常见的在两个表之间拷贝数据的方法。你需要注意,在可重复读隔离级别下,这个语句会给 select 的表里扫描到的记录和间隙加读锁。
在 MySQL 8.0 版本中,已经能够用临时表处理 insert … select 写入原表的语句了
insert 循环写入
当然了,执行 insert … select 的时候,对目标表也不是锁全表,而是只锁住需要访问的资源。
如果现在有这么一个需求:要往表 t2 中插入一行数据,这一行的 c 值是表 t 中 c 值的最大值加 1。
此时,我们可以这么写这条 SQL 语句 :
1 | insert into t2(c,d) (select c+1, d from t force index(c) order by c desc limit 1); |
这个语句的加锁范围,就是表 t 索引 c 上的 (3,4] 和 (4,supremum] 这两个 next-key lock,以及主键索引上 id=4 这一行。
它的执行流程也比较简单,从表 t 中按照索引 c 倒序,扫描第一行,拿到结果写入到表 t2 中。
因此整条语句的扫描行数是 1。
这个语句执行的慢查询日志(slow log),如下图所示:
通过这个慢查询日志,我们看到 Rows_examined=1,正好验证了执行这条语句的扫描行数为 1。
那么,如果我们是要把这样的一行数据插入到表 t 中的话:
1 | insert into t(c,d) (select c+1, d from t force index(c) order by c desc limit 1); |
语句的执行流程是怎样的?扫描行数又是多少呢?
这时候,我们再看慢查询日志就会发现不对了。
可以看到,这时候的 Rows_examined 的值是 5。
我在前面的文章中提到过,希望你都能够学会用 explain 的结果来“脑补”整条语句的执行过程。今天,我们就来一起试试。
如图 4 所示就是这条语句的 explain 结果。
从 Extra 字段可以看到“Using temporary”字样,表示这个语句用到了临时表。也就是说,执行过程中,需要把表 t 的内容读出来,写入临时表。
图中 rows 显示的是 1,我们不妨先对这个语句的执行流程做一个猜测:如果说是把子查询的结果读出来(扫描 1 行),写入临时表,然后再从临时表读出来(扫描 1 行),写回表 t 中。那么,这个语句的扫描行数就应该是 2,而不是 5。
所以,这个猜测不对。实际上,Explain 结果里的 rows=1 是因为受到了 limit 1 的影响。
从另一个角度考虑的话,我们可以看看 InnoDB 扫描了多少行。如图 5 所示,是在执行这个语句前后查看 Innodb_rows_read 的结果。
这样,我们就把整个执行过程理清楚了:
- 创建临时表,表里有两个字段 c 和 d。
- 按照索引 c 扫描表 t,依次取 c=4、3、2、1,然后回表,读到 c 和 d 的值写入临时表。这时,Rows_examined=4。
- 由于语义里面有 limit 1,所以只取了临时表的第一行,再插入到表 t 中。这时,Rows_examined 的值加 1,变成了 5。
也就是说,这个语句会导致在表 t 上做全表扫描,并且会给索引 c 上的所有间隙都加上共享的 next-key lock。所以,这个语句执行期间,其他事务不能在这个表上插入数据。
至于这个语句的执行为什么需要临时表,原因是这类一边遍历数据,一边更新数据的情况,如果读出来的数据直接写回原表,就可能在遍历过程中,读到刚刚插入的记录,新插入的记录如果参与计算逻辑,就跟语义不符。
由于实现上这个语句没有在子查询中就直接使用 limit 1,从而导致了这个语句的执行需要遍历整个表 t。它的优化方法也比较简单,就是用前面介绍的方法,先 insert into 到临时表 temp_t,这样就只需要扫描一行;然后再从表 temp_t 里面取出这行数据插入表 t1。
当然,由于这个语句涉及的数据量很小,你可以考虑使用内存临时表来做这个优化。使用内存临时表优化时,语句序列的写法如下:
1 | create temporary table temp_t(c int,d int) engine=memory; |
而如果 insert 和 select 的对象是同一个表,则有可能会造成循环写入。这种情况下,我们需要引入用户临时表来做优化。
insert 唯一键冲突
前面的两个例子是使用 insert … select 的情况,接下来我要介绍的这个例子就是最常见的 insert 语句出现唯一键冲突的情况。
对于有唯一键的表,插入数据时出现唯一键冲突也是常见的情况了。我先给你举一个简单的唯一键冲突的例子。
这个例子也是在可重复读(repeatable read)隔离级别下执行的。可以看到,session B 要执行的 insert 语句进入了锁等待状态。
也就是说,session A 执行的 insert 语句,发生唯一键冲突的时候,并不只是简单地报错返回,还在冲突的索引上加了锁。我们前面说过,一个 next-key lock 就是由它右边界的值定义的。这时候,session A 持有索引 c 上的 (5,10] 共享 next-key lock(读锁)。
至于为什么要加这个读锁,其实我也没有找到合理的解释。从作用上来看,这样做可以避免这一行被别的事务删掉。
这里官方文档有一个描述错误,认为如果冲突的是主键索引,就加记录锁,唯一索引才加 next-key lock。但实际上,这两类索引冲突加的都是 next-key lock。
备注:这个 bug,是我在写这篇文章查阅文档时发现的,已经发给官方并被 verified 了。
有同学在前面文章的评论区问到,在有多个唯一索引的表中并发插入数据时,会出现死锁。但是,由于他没有提供复现方法或者现场,我也无法做分析。所以,我建议你在评论区发问题的时候,尽量同时附上复现方法,或者现场信息,这样我才好和你一起分析问题。
这里,我就先和你分享一个经典的死锁场景,如果你还遇到过其他唯一键冲突导致的死锁场景,也欢迎给我留言。
在 session A 执行 rollback 语句回滚的时候,session C 几乎同时发现死锁并返回。
这个死锁产生的逻辑是这样的:
- 在 T1 时刻,启动 session A,并执行 insert 语句,此时在索引 c 的 c=5 上加了记录锁。注意,这个索引是唯一索引,因此退化为记录锁(如果你的印象模糊了,可以回顾下[[实践篇-02 16-22节#21 为什么我只改一行的语句,锁这么多?]]介绍的加锁规则)。
- 在 T2 时刻,session B 要执行相同的 insert 语句,发现了唯一键冲突,加上读锁;同样地,session C 也在索引 c 上,c=5 这一个记录上,加了读锁。
这里加读锁作者觉得是为了防止这个记录再被删除(不过这个理由不是很硬,作者还没有找到其他解释
如果 sessionA 拿到 c=5 的记录锁是写锁,那为什么 sessionB 和 sessionC 还能加 c=5 的读锁呢?
这是因为 next-key lock 是先加间隙锁,再加记录锁的。加间隙锁成功了,加记录锁就会被堵住。可以复习[[实践篇-04 31-38节#30 答疑文章(二):用动态的观点看加锁]]
- T3 时刻,session A 回滚。这时候,session B 和 session C 都试图继续执行插入操作,都要加上写锁。两个 session 都要等待对方的行锁,所以就出现了死锁。
这个流程的状态变化图如下所示。
insert 语句如果出现唯一键冲突,会在冲突的唯一值上加共享的 next-key lock(S 锁)。因此,碰到由于唯一键约束导致报错后,要尽快提交或回滚事务,避免加锁时间过长。
insert into … on duplicate key update
上面这个例子是主键冲突后直接报错,如果是改写成
1 | insert into t values(11,10,10) on duplicate key update d=100; |
的话,就会给索引 c 上 (5,10] 加一个排他的 next-key lock(写锁)。
发现冲突时直接加写锁,而不是先读锁,后变成写锁
insert into … on duplicate key update 这个语义的逻辑是,插入一行数据,如果碰到唯一键约束,就执行后面的更新语句。
注意,如果有多个列违反了唯一性约束,就会按照索引的顺序,修改跟第一个索引冲突的行。
现在表 t 里面已经有了 (1,1,1) 和 (2,2,2) 这两行,我们再来看看下面这个语句执行的效果:
可以看到,主键 id 是先判断的,MySQL 认为这个语句跟 id=2 这一行冲突,所以修改的是 id=2 的行。
需要注意的是,执行这条语句的 affected rows 返回的是 2,很容易造成误解。实际上,真正更新的只有一行,只是在代码实现上,insert 和 update 都认为自己成功了,update 计数加了 1, insert 计数也加了 1。
小结
今天这篇文章,我和你介绍了几种特殊情况下的 insert 语句。
insert … select 是很常见的在两个表之间拷贝数据的方法。你需要注意,在可重复读隔离级别下,这个语句会给 select 的表里扫描到的记录和间隙加读锁。
而如果 insert 和 select 的对象是同一个表,则有可能会造成循环写入。这种情况下,我们需要引入用户临时表来做优化。
insert 语句如果出现唯一键冲突,会在冲突的唯一值上加共享的 next-key lock(S 锁)。因此,碰到由于唯一键约束导致报错后,要尽快提交或回滚事务,避免加锁时间过长。
问题
你平时在两个表之间拷贝数据用的是什么方法,有什么注意事项吗?在你的应用场景里,这个方法,相较于其他方法的优势是什么呢?
来自老杨同志的回答:
我用的最多还是insert into select 。如果数量比较大,会加上limit 100,000这种。并且看看后面的select条件是否走索引。缺点是会锁select的表。方法二:导出成excel,然后拼sql 成 insert into values(),(),()的形式。方法3,写类似淘宝调动的定时任务,任务的逻辑是查询100条记录,然后多个线程分到几个任务执行
关于老师的回答可以见下一篇文章
其他问题
[!question]
老师,死锁的例子,关于sessionA拿到的c=5的记录锁,sessionB和sessionC发现唯一键冲突会加上读锁我有几个疑惑:
1、sessionA拿到的c=5的记录锁是写锁吗?
2、为什么sessionB和sessionC发现唯一键冲突会加上读锁?
3、如果sessionA拿到c=5的记录所是写锁,那为什么sessionB和sessionC还能加c=5的读锁,写锁和读锁不应该是互斥的吗?
4、sessionA还没有提交,为什么sessionB和sessionC能发现唯一键冲突?
作者回复: 1. 是的
2. 这个我觉得是为了防止这个记录再被删除(不过这个理由不是很硬,我还没有找到其他解释
3. 互斥的,所以这两个语句都在等待。注意next-key lock是由间隙锁和记录锁组成的哦, 间隙锁加成功了的。好问题。
4. 还没有提交,但是这个记录已经作为最新记录写进去了,复习一下08篇哈【c是唯一索引,插入数据需要先读数据,判断没有冲突再写。读的时候是当前读,所以可以看到A插入的数据。如果把c的唯一索引去掉,就不需要判断是否有冲突了,这时候session B和session C都可以执行成功】
【session B和session C开始插入c=5写锁 ,都因为要等待对方释放 c=5 读锁而产生死锁】
[!question]
我一直以来的认知是 gap lock都是s锁,没有x锁。
而行锁有s锁和x锁。 比如 select………lock in share mode,行锁是s 锁。
比如select………for update,行锁就是x锁。
但是gap lock 始终是s锁。
是这样的,gap lock是无所谓S还是X的。 但是record lock 有。 Gap lock + 排他的record 就称作 排他的next-key lock 吧😄
间隙锁是解决幻读问题的,不管属于读锁还是写锁,都会阻塞其他事务的写请求。从这个角度来看,也就是无所谓读锁还是写锁了。
41 | 怎么最快地复制一张表?
我在上一篇文章最后,给你留下的问题是怎么在两张表中拷贝数据。如果可以控制对源表的扫描行数和加锁范围很小的话,我们简单地使用 insert … select 语句即可实现。
当然,为了避免对源表加读锁,更稳妥的方案是先将数据写到外部文本文件,然后再写回目标表。这时,有两种常用的方法。接下来的内容,我会和你详细展开一下这两种方法。
为了便于说明,我还是先创建一个表 db1.t,并插入 1000 行数据,同时创建一个相同结构的表 db2.t。
1 | create database db1; |
假设,我们要把 db1.t 里面 a>900 的数据行导出来,插入到 db2.t 中。
mysqldump 方法
一种方法是,使用 mysqldump 命令将数据导出成一组 INSERT 语句。你可以使用下面的命令:
1 | mysqldump -h$host -P$port -u$user --add-locks=0 --no-create-info --single-transaction --set-gtid-purged=OFF db1 t --where="a>900" --result-file=/client_tmp/t.sql |
把结果输出到临时文件。
这条命令中,主要参数含义如下:
- –single-transaction 的作用是,在导出数据的时候不需要对表 db1.t 加表锁,而是使用 START TRANSACTION WITH CONSISTENT SNAPSHOT 的方法;
- –add-locks 设置为 0,表示在输出的文件结果里,不增加” LOCK TABLES
t
WRITE;” ; - –no-create-info 的意思是,不需要导出表结构;
- –set-gtid-purged=off 表示的是,不输出跟 GTID 相关的信息;
- –result-file 指定了输出文件的路径,其中 client 表示生成的文件是在客户端机器上的。
通过这条 mysqldump 命令生成的 t.sql 文件中就包含了如图 1 所示的 INSERT 语句。
可以看到,一条 INSERT 语句里面会包含多个 value 对,这是为了后续用这个文件来写入数据的时候,执行速度可以更快。
如果你希望生成的文件中一条 INSERT 语句只插入一行数据的话,可以在执行 mysqldump 命令时,加上参数 –skip-extended-insert。
然后,你可以通过下面这条命令,将这些 INSERT 语句放到 db2 库里去执行。
1 | mysql -h127.0.0.1 -P13000 -uroot db2 -e "source /client_tmp/t.sql" |
需要说明的是,source 并不是一条 SQL 语句,而是一个客户端命令。mysql 客户端执行这个命令的流程是这样的:
- 打开文件,默认以分号为结尾读取一条条的 SQL 语句;
- 将 SQL 语句发送到服务端执行。
也就是说,服务端执行的并不是这个“source t.sql”语句,而是 INSERT 语句。所以,不论是在慢查询日志(slow log),还是在 binlog,记录的都是这些要被真正执行的 INSERT 语句。
导出 CSV 文件
另一种方法是直接将结果导出成.csv 文件。MySQL 提供了下面的语法,用来将查询结果导出到服务端本地目录:
1 | select * from db1.t where a>900 into outfile '/server_tmp/t.csv'; |
我们在使用这条语句时,需要注意如下几点。
- 这条语句会将结果保存在服务端。如果你执行命令的客户端和 MySQL 服务端不在同一个机器上,客户端机器的临时目录下是不会生成 t.csv 文件的。
- into outfile 指定了文件的生成位置(/server_tmp/),这个位置必须受参数 secure_file_priv 的限制。参数 secure_file_priv 的可选值和作用分别是:
- 如果设置为 empty,表示不限制文件生成的位置,这是不安全的设置;
- 如果设置为一个表示路径的字符串,就要求生成的文件只能放在这个指定的目录,或者它的子目录;
- 如果设置为 NULL,就表示禁止在这个 MySQL 实例上执行 select … into outfile 操作。
- 这条命令不会帮你覆盖文件,因此你需要确保 /server_tmp/t.csv 这个文件不存在,否则执行语句时就会因为有同名文件的存在而报错。
- 这条命令生成的文本文件中,原则上一个数据行对应文本文件的一行。但是,如果字段中包含换行符,在生成的文本中也会有换行符。不过类似换行符、制表符这类符号,前面都会跟上“\”这个转义符,这样就可以跟字段之间、数据行之间的分隔符区分开。
得到.csv 导出文件后,你就可以用下面的 load data 命令将数据导入到目标表 db2.t 中。
1 | load data infile '/server_tmp/t.csv' into table db2.t; |
这条语句的执行流程如下所示。
- 打开文件 /server_tmp/t.csv,以制表符 (\t) 作为字段间的分隔符,以换行符(\n)作为记录之间的分隔符,进行数据读取;
- 启动事务。
- 判断每一行的字段数与表 db2.t 是否相同:
- 若不相同,则直接报错,事务回滚;
- 若相同,则构造成一行,调用 InnoDB 引擎接口,写入到表中。
- 重复步骤 3,直到 /server_tmp/t.csv 整个文件读入完成,提交事务。
你可能有一个疑问,如果 binlog_format=statement,这个 load 语句记录到 binlog 里以后,怎么在备库重放呢?
由于 /server_tmp/t.csv 文件只保存在主库所在的主机上,如果只是把这条语句原文写到 binlog 中,在备库执行的时候,备库的本地机器上没有这个文件,就会导致主备同步停止。
所以,这条语句执行的完整流程,其实是下面这样的。
- 主库执行完成后,将 /server_tmp/t.csv 文件的内容直接写到 binlog 文件中。
- 往 binlog 文件中写入语句 load data local infile ‘/tmp/SQL_LOAD_MB-1-0’ INTO TABLE
db2
.t
。 - 把这个 binlog 日志传到备库。
- 备库的 apply 线程在执行这个事务日志时:
a. 先将 binlog 中 t.csv 文件的内容读出来,写入到本地临时目录 /tmp/SQL_LOAD_MB-1-0 中;
b. 再执行 load data 语句,往备库的 db2.t 表中插入跟主库相同的数据。
执行流程如图 2 所示:
注意,这里备库执行的 load data 语句里面,多了一个“local”。它的意思是“将执行这条命令的客户端所在机器的本地文件 /tmp/SQL_LOAD_MB-1-0 的内容,加载到目标表 db2.t 中”。
也就是说,load data 命令有两种用法:
- 不加“local”,是读取服务端的文件,这个文件必须在 secure_file_priv 指定的目录或子目录下;
- 加上“local”,读取的是客户端的文件,只要 mysql 客户端有访问这个文件的权限即可。这时候,MySQL 客户端会先把本地文件传给服务端,然后执行上述的 load data 流程。
另外需要注意的是,select …into outfile 方法不会生成表结构文件, 所以我们导数据时还需要单独的命令得到表结构定义。mysqldump 提供了一个–tab 参数,可以同时导出表结构定义文件和 csv 数据文件。这条命令的使用方法如下:
1 | mysqldump -h$host -P$port -u$user ---single-transaction --set-gtid-purged=OFF db1 t --where="a>900" --tab=$secure_file_priv |
这条命令会在 $secure_file_priv 定义的目录下,创建一个 t.sql 文件保存建表语句,同时创建一个 t.txt 文件保存 CSV 数据。
物理拷贝方法
前面我们提到的 mysqldump 方法和导出 CSV 文件的方法,都是逻辑导数据的方法,也就是将数据从表 db1.t 中读出来,生成文本,然后再写入目标表 db2.t 中。
你可能会问,有物理导数据的方法吗?比如,直接把 db1.t 表的.frm 文件和.ibd 文件拷贝到 db2 目录下,是否可行呢?
答案是不行的。
因为,一个 InnoDB 表,除了包含这两个物理文件外,还需要在数据字典中注册。直接拷贝这两个文件的话,因为数据字典中没有 db2.t 这个表,系统是不会识别和接受它们的。
不过,在 MySQL 5.6 版本引入了可传输表空间(transportable tablespace) 的方法,可以通过导出 + 导入表空间的方式,实现物理拷贝表的功能。
假设我们现在的目标是在 db1 库下,复制一个跟表 t 相同的表 r,具体的执行步骤如下:
- 执行 create table r like t,创建一个相同表结构的空表;
- 执行 alter table r discard tablespace,这时候 r.ibd 文件会被删除;
- 执行 flush table t for export,这时候 db1 目录下会生成一个 t.cfg 文件;
- 在 db1 目录下执行 cp t.cfg r.cfg; cp t.ibd r.ibd;这两个命令(这里需要注意的是,拷贝得到的两个文件,MySQL 进程要有读写权限);
- 执行 unlock tables,这时候 t.cfg 文件会被删除;
- 执行 alter table r import tablespace,将这个 r.ibd 文件作为表 r 的新的表空间,由于这个文件的数据内容和 t.ibd 是相同的,所以表 r 中就有了和表 t 相同的数据。
ibd是索引和数据的文件
至此,拷贝表数据的操作就完成了。这个流程的执行过程图如下:
关于拷贝表的这个流程,有以下几个注意点:
- 在第 3 步执行完 flsuh table 命令之后,db1.t 整个表处于只读状态,直到执行 unlock tables 命令后才释放读锁;
- 在执行 import tablespace 的时候,为了让文件里的表空间 id 和数据字典中的一致,会修改 r.ibd 的表空间 id。而这个表空间 id 存在于每一个数据页中。因此,如果是一个很大的文件(比如 TB 级别),每个数据页都需要修改,所以你会看到这个 import 语句的执行是需要一些时间的。当然,如果是相比于逻辑导入的方法,import 语句的耗时是非常短的。
小结
我们来对比一下这三种方法的优缺点。
- 物理拷贝的方式速度最快,尤其对于大表拷贝来说是最快的方法。如果出现误删表的情况,用备份恢复出误删之前的临时库,然后再把临时库中的表拷贝到生产库上,是恢复数据最快的方法。但是,这种方法的使用也有一定的局限性:
- 必须是全表拷贝,不能只拷贝部分数据;
- 需要到服务器上拷贝数据,在用户无法登录数据库主机的场景下无法使用;
- 由于是通过拷贝物理文件实现的,源表和目标表都是使用 InnoDB 引擎时才能使用。
- 用 mysqldump 生成包含 INSERT 语句文件的方法,可以在 where 参数增加过滤条件,来实现只导出部分数据。这个方式的不足之一是,不能使用 join 这种比较复杂的 where 条件写法。
- 用 select … into outfile 的方法是最灵活的,支持所有的 SQL 写法。但,这个方法的缺点之一就是,每次只能导出一张表的数据,而且表结构也需要另外的语句单独备份。
后两种方式都是逻辑备份方式,是可以跨引擎使用的。
问题
我们前面介绍 binlog_format=statement 的时候,binlog 记录的 load data 命令是带 local 的。既然这条命令是发送到备库去执行的,那么备库执行的时候也是本地执行,为什么需要这个 local 呢?如果写到 binlog 中的命令不带 local,又会出现什么问题呢?
这样做的一个原因是,为了确保备库应用 binlog 正常。因为备库可能配置了 secure_file_priv=null,所以如果不用 local 的话,可能会导入失败,造成主备同步延迟。
另一种应用场景是使用 mysqlbinlog 工具解析 binlog 文件,并应用到目标库的情况。你可以使用下面这条命令 :
1 | mysqlbinlog $binlog_file | mysql -h$host -P$port -u$user -p$pwd |
把日志直接解析出来发给目标库执行。增加 local,就能让这个方法支持非本地的 $host。
42 | grant之后要跟着flush privileges吗?
在 MySQL 里面,grant 语句是用来给用户赋权的。不知道你有没有见过一些操作文档里面提到,grant 之后要马上跟着执行一个 flush privileges 命令,才能使赋权语句生效。我最开始使用 MySQL 的时候,就是照着一个操作文档的说明按照这个顺序操作的。
那么,grant 之后真的需要执行 flush privileges 吗?如果没有执行这个 flush 命令的话,赋权语句真的不能生效吗?
接下来,我就先和你介绍一下 grant 语句和 flush privileges 语句分别做了什么事情,然后再一起来分析这个问题。
为了便于说明,我先创建一个用户:
1 | create user 'ua'@'%' identified by 'pa'; |
这条语句的逻辑是创建一个用户’ua’@’%’,密码是 pa。注意,在 MySQL 里面,用户名 (user)+ 地址 (host) 才表示一个用户,因此 ua@ip1 和 ua@ip2 代表的是两个不同的用户。【仅以用户名以为是用户的话,可能就会误以为有权限实际没有】
这条命令做了两个动作:
- 磁盘上,往 mysql.user 表里插入一行,由于没有指定权限,所以这行数据上所有表示权限的字段的值都是 N;
- 内存里,往数组 acl_users 里插入一个 acl_user 对象,这个对象的 access 字段值为 0。
图 1 就是这个时刻用户 ua 在 user 表中的状态。
在 MySQL 中,用户权限是有不同的范围的。接下来,我就按照用户权限范围从大到小的顺序依次和你说明。
全局权限
全局权限,作用于整个 MySQL 实例,这些权限信息保存在 mysql 库的 user 表里。如果我要给用户 ua 赋一个最高权限的话,语句是这么写的:
1 | grant all privileges on *.* to 'ua'@'%' with grant option; |
这个 grant 命令做了两个动作:
- 磁盘上,将 mysql.user 表里,用户’ua’@’%’这一行的所有表示权限的字段的值都修改为 ‘Y’;
- 内存里,从数组 acl_users 中找到这个用户对应的对象,将 access 值(权限位)修改为二进制的“全 1”。
在这个 grant 命令执行完成后,如果有新的客户端使用用户名 ua 登录成功,MySQL 会为新连接维护一个线程对象,然后从 acl_users 数组里查到这个用户的权限,并将权限值拷贝到这个线程对象中。之后在这个连接中执行的语句,所有关于全局权限的判断,都直接使用线程对象内部保存的权限位。
基于上面的分析我们可以知道:
- grant 命令对于全局权限,同时更新了磁盘和内存。命令完成后即时生效,接下来新创建的连接会使用新的权限。
- 对于一个已经存在的连接,它的全局权限不受 grant 命令的影响。
需要说明的是,一般在生产环境上要合理控制用户权限的范围。我们上面用到的这个 grant 语句就是一个典型的错误示范。如果一个用户有所有权限,一般就不应该设置为所有 IP 地址都可以访问。
如果要回收上面的 grant 语句赋予的权限,你可以使用下面这条命令:
1 | revoke all privileges on *.* from 'ua'@'%'; |
这条 revoke 命令的用法与 grant 类似,做了如下两个动作:
- 磁盘上,将 mysql.user 表里,用户’ua’@’%’这一行的所有表示权限的字段的值都修改为“N”;
- 内存里,从数组 acl_users 中找到这个用户对应的对象,将 access 的值修改为 0。
db 权限
除了全局权限,MySQL 也支持库级别的权限定义。如果要让用户 ua 拥有库 db1 的所有权限,可以执行下面这条命令:
1 | grant all privileges on db1.* to 'ua'@'%' with grant option; |
基于库的权限记录保存在 mysql.db 表中,在内存里则保存在数组 acl_dbs 中。这条 grant 命令做了如下两个动作:
- 磁盘上,往 mysql.db 表中插入了一行记录,所有权限位字段设置为“Y”;
- 内存里,增加一个对象到数组 acl_dbs 中,这个对象的权限位为“全 1”。
图 2 就是这个时刻用户 ua 在 db 表中的状态。
每次需要判断一个用户对一个数据库读写权限的时候,都需要遍历一次 acl_dbs 数组,根据 user、host 和 db 找到匹配的对象,然后根据对象的权限位来判断。【和全局权限不同,它没有保存线程缓存】
也就是说,grant 修改 db 权限的时候,是同时对磁盘和内存生效的。
grant 操作对于已经存在的连接的影响,在全局权限和基于 db 的权限效果是不同的。接下来,我们做一个对照试验来分别看一下。
需要说明的是,图中 set global sync_binlog 这个操作是需要 super 权限的。
可以看到,虽然用户 ua 的 super 权限在 T3 时刻已经通过 revoke 语句回收了,但是在 T4 时刻执行 set global 的时候,权限验证还是通过了。这是因为 super 是全局权限,这个权限信息在线程对象中,而 revoke 操作影响不到这个线程对象。
而在 T5 时刻去掉 ua 对 db1 库的所有权限后,在 T6 时刻 session B 再操作 db1 库的表,就会报错“权限不足”。这是因为 acl_dbs 是一个全局数组,所有线程判断 db 权限都用这个数组,这样 revoke 操作马上就会影响到 session B。
这里在代码实现上有一个特别的逻辑,如果当前会话已经处于某一个 db 里面,之前 use 这个库的时候拿到的库权限会保存在会话变量中。
你可以看到在 T6 时刻,session C 和 session B 对表 t 的操作逻辑是一样的。但是 session B 报错,而 session C 可以执行成功。这是因为 session C 在 T2 时刻执行的 use db1,拿到了这个库的权限,在切换出 db1 库之前,session C 对这个库就一直有权限。
总结: 对于全局权限,因为全局权限存储在线程对象中,所以修改用户的全局权限后,不会影响到已经存在的连接; 对于数据库权限,因为acl_dbs是一个全局数组,修改用户的数据库权限,acl_dbs也会立马随之修改,线程对象可以立刻读到,所以会直接影响到已经存在的连接。
表权限和列权限
除了 db 级别的权限外,MySQL 支持更细粒度的表权限和列权限。其中,表权限定义存放在表 mysql.tables_priv 中,列权限定义存放在表 mysql.columns_priv 中。这两类权限,组合起来存放在内存的 hash 结构 column_priv_hash 中。
这两类权限的赋权命令如下:
1 | create table db1.t1(id int, a int); |
跟 db 权限类似,这两个权限每次 grant 的时候都会修改数据表,也会同步修改内存中的 hash 结构。因此,对这两类权限的操作,也会马上影响到已经存在的连接。
看到这里,你一定会问,看来 grant 语句都是即时生效的,那这么看应该就不需要执行 flush privileges 语句了呀。
答案也确实是这样的。
flush privileges 命令会清空 acl_users 数组,然后从 mysql.user 表中读取数据重新加载,重新构造一个 acl_users 数组。也就是说,以数据表中的数据为准,会将全局权限内存数组重新加载一遍。
同样地,对于 db 权限、表权限和列权限,MySQL 也做了这样的处理。
也就是说,如果内存的权限数据和磁盘数据表相同的话,不需要执行 flush privileges。而如果我们都是用 grant/revoke 语句来执行的话,内存和数据表本来就是保持同步更新的。
因此,正常情况下,grant 命令之后,没有必要跟着执行 flush privileges 命令。
flush privileges 使用场景
那么,flush privileges 是在什么时候使用呢?显然,当数据表中的权限数据跟内存中的权限数据不一致的时候,flush privileges 语句可以用来重建内存数据,达到一致状态。
这种不一致往往是由不规范的操作导致的,比如直接用 DML 语句操作系统权限表。我们来看一下下面这个场景:
可以看到,T3 时刻虽然已经用 delete 语句删除了用户 ua,但是在 T4 时刻,仍然可以用 ua 连接成功。原因就是,这时候内存中 acl_users 数组中还有这个用户,因此系统判断时认为用户还正常存在。
在 T5 时刻执行过 flush 命令后,内存更新,T6 时刻再要用 ua 来登录的话,就会报错“无法访问”了。
直接操作系统表是不规范的操作,这个不一致状态也会导致一些更“诡异”的现象发生。比如,前面这个通过 delete 语句删除用户的例子,就会出现下面的情况:
可以看到,由于在 T3 时刻直接删除了数据表的记录,而内存的数据还存在。这就导致了:
- T4 时刻给用户 ua 赋权限失败,因为 mysql.user 表中找不到这行记录;
- 而 T5 时刻要重新创建这个用户也不行,因为在做内存判断的时候,会认为这个用户还存在。
小结
今天这篇文章,我和你介绍了 MySQL 用户权限在数据表和内存中的存在形式,以及 grant 和 revoke 命令的执行逻辑。
grant 语句会同时修改数据表和内存,判断权限的时候使用的是内存数据。因此,规范地使用 grant 和 revoke 语句,是不需要随后加上 flush privileges 语句的。
flush privileges 语句本身会用数据表的数据重建一份内存权限数据,所以在权限数据可能存在不一致的情况下再使用。而这种不一致往往是由于直接用 DML 语句操作系统权限表导致的,所以我们尽量不要使用这类语句。
另外,在使用 grant 语句赋权时,你可能还会看到这样的写法:
1 | grant super on *.* to 'ua'@'%' identified by 'pa'; |
这条命令加了 identified by ‘密码’, 语句的逻辑里面除了赋权外,还包含了:
- 如果用户’ua’@’%’不存在,就创建这个用户,密码是 pa;
- 如果用户 ua 已经存在,就将密码修改成 pa。
这也是一种不建议的写法,因为这种写法很容易就会不慎把密码给改了。
“grant 之后随手加 flush privileges”,我自己是这么使用了两三年之后,在看代码的时候才发现其实并不需要这样做,那已经是 2011 年的事情了。
其他问题
[!question]
写个比较小的点:在命令行查询数据需要行转列的时候习惯加个\G ; 比如slave slave stauts \G ; 后来发现 ; 是多余的。列几个常用的
\G 行转列并发送给 mysql server
\g 等同于 ;
\! 执行系统命令
\q exit
\c 清除当前SQL(不执行)
\s mysql status 信息
其他参考 \h
作者回复: 👍
我最开始使用MySQL的时候,就是不自然的在\G后面加分号
而且还看到报错,好紧张😆【所以\G后不需要加分好】
[!question]
老师我使用delte删除用户,再创建用户都是失败,但是使用drop就可以了
是不是drop才会同时从内存和磁盘删除用户信息,但是delete只是从磁盘删除?
作者回复: 对,drop是同时操作磁盘和内存,
delete就是我们说的不规范操作
其他收获
grant 的时候是支持通配符的:”_“表示一个任意字符,“%”表示任意字符串。这个技巧在一个分库分表方案里面,同一个分库上有多个 db 的时候,是挺方便的。不过我个人认为,权限赋值的时候,控制的精确性还是要优先考虑的。
MySQL 建议每次如果要加列都加到最后一列。
1、因为MySQL的数据库表的列是有序的,如果在表的中间位置添加列,则需要进行表的重构,这会导致数据的移动和索引的重建,影响系统的性能。
2、访问表的列是按顺序进行的。如果在表的中间位置添加列,可能会导致查询语句需要访问更多的磁盘块,从而影响查询的性能。
3、便于维护和管理。
43 | 要不要使用分区表?
我经常被问到这样一个问题:分区表有什么问题,为什么公司规范不让使用分区表呢?今天,我们就来聊聊分区表的使用行为,然后再一起回答这个问题。
分区表是什么?
为了说明分区表的组织形式,我先创建一个表 t:
1 | CREATE TABLE `t` ( |
我在表 t 中初始化插入了两行记录,按照定义的分区规则,这两行记录分别落在 p_2018 和 p_2019 这两个分区上。
可以看到,这个表包含了一个.frm 文件和 4 个.ibd 文件,每个分区对应一个.ibd 文件。也就是说:
- 对于引擎层来说,这是 4 个表;
- 对于 Server 层来说,这是 1 个表。
你可能会觉得这两句都是废话。其实不然,这两句话非常重要,可以帮我们理解分区表的执行逻辑。
分区表的引擎层行为
我先给你举个在分区表加间隙锁的例子,目的是说明对于 InnoDB 来说,这是 4 个表。
这里顺便复习一下,我在[[实践篇-02 16-22节#21 为什么我只改一行的语句,锁这么多?]]和你介绍的间隙锁加锁规则。
我们初始化表 t 的时候,只插入了两行数据, ftime 的值分别是,‘2017-4-1’ 和’2018-4-1’ 。session A 的 select 语句对索引 ftime 上这两个记录之间的间隙加了锁。如果是一个普通表的话,那么 T1 时刻,在表 t 的 ftime 索引上,间隙和加锁状态应该是图 3 这样的。
也就是说,‘2017-4-1’ 和’2018-4-1’ 这两个记录之间的间隙是会被锁住的。那么,sesion B 的两条插入语句应该都要进入锁等待状态。
但是,从上面的实验效果可以看出,session B 的第一个 insert 语句是可以执行成功的。这是因为,对于引擎来说,p_2018 和 p_2019 是两个不同的表,也就是说 2017-4-1 的下一个记录并不是 2018-4-1,而是 p_2018 分区的 supremum。所以 T1 时刻,在表 t 的 ftime 索引上,间隙和加锁的状态其实是图 4 这样的:
由于分区表的规则,session A 的 select 语句其实只操作了分区 p_2018,因此加锁范围就是图 4 中深绿色的部分。
所以,session B 要写入一行 ftime 是 2018-2-1 的时候是可以成功的,而要写入 2017-12-1 这个记录,就要等 session A 的间隙锁。
图 5 就是这时候的 show engine innodb status 的部分结果。
看完 InnoDB 引擎的例子,我们再来一个 MyISAM 分区表的例子。
我首先用 alter table t engine=myisam,把表 t 改成 MyISAM 表;然后,我再用下面这个例子说明,对于 MyISAM 引擎来说,这是 4 个表。
在 session A 里面,我用 sleep(100) 将这条语句的执行时间设置为 100 秒。由于 MyISAM 引擎只支持表锁,所以这条 update 语句会锁住整个表 t 上的读。
但我们看到的结果是,session B 的第一条查询语句是可以正常执行的,第二条语句才进入锁等待状态。
这正是因为 MyISAM 的表锁是在引擎层实现的,session A 加的表锁,其实是锁在分区 p_2018 上。因此,只会堵住在这个分区上执行的查询,落到其他分区的查询是不受影响的。
看到这里,你可能会说,分区表看来还不错嘛,为什么不让用呢?我们使用分区表的一个重要原因就是单表过大。那么,如果不使用分区表的话,我们就是要使用手动分表的方式。
接下来,我们一起看看手动分表和分区表有什么区别。
比如,按照年份来划分,我们就分别创建普通表 t_2017、t_2018、t_2019 等等。手工分表的逻辑,也是找到需要更新的所有分表,然后依次执行更新。在性能上,这和分区表并没有实质的差别。
分区表和手工分表,一个是由 server 层来决定使用哪个分区,一个是由应用层代码来决定使用哪个分表。因此,从引擎层看,这两种方式也是没有差别的。
其实这两个方案的区别,主要是在 server 层上。从 server 层看,我们就不得不提到分区表一个被广为诟病的问题:打开表的行为。
分区策略
每当第一次访问一个分区表的时候,MySQL 需要把所有的分区都访问一遍。一个典型的报错情况是这样的:如果一个分区表的分区很多,比如超过了 1000 个,而 MySQL 启动的时候,open_files_limit 参数使用的是默认值 1024,那么就会在访问这个表的时候,由于需要打开所有的文件,导致打开表文件的个数超过了上限而报错。
下图就是我创建的一个包含了很多分区的表 t_myisam,执行一条插入语句后报错的情况。
可以看到,这条 insert 语句,明显只需要访问一个分区,但语句却无法执行。
这时,你一定从表名猜到了,这个表我用的是 MyISAM 引擎。是的,因为使用 InnoDB 引擎的话,并不会出现这个问题。
MyISAM 分区表使用的分区策略,我们称为通用分区策略(generic partitioning),每次访问分区都由 server 层控制。通用分区策略,是 MySQL 一开始支持分区表的时候就存在的代码,在文件管理、表管理的实现上很粗糙,因此有比较严重的性能问题。
从 MySQL 5.7.9 开始,InnoDB 引擎引入了本地分区策略(native partitioning)。这个策略是在 InnoDB 内部自己管理打开分区的行为。
MySQL 从 5.7.17 开始,将 MyISAM 分区表标记为即将弃用 (deprecated),意思是“从这个版本开始不建议这么使用,请使用替代方案。在将来的版本中会废弃这个功能”。
从 MySQL 8.0 版本开始,就不允许创建 MyISAM 分区表了,只允许创建已经实现了本地分区策略的引擎。目前来看,只有 InnoDB 和 NDB 这两个引擎支持了本地分区策略。
接下来,我们再看一下分区表在 server 层的行为。
分区表的 server 层行为
如果从 server 层看的话,一个分区表就只是一个表。
个人理解,作者的意思,这里应该是:多个分区表就只是一个表。(可能是笔误)
这句话是什么意思呢?接下来,我就用下面这个例子来和你说明。如图 8 和图 9 所示,分别是这个例子的操作序列和执行结果图。
可以看到,虽然 session B 只需要操作 p_2107 这个分区,但是由于 session A 持有整个表 t 的 MDL 锁,就导致了 session B 的 alter 语句被堵住。
这也是 DBA 同学经常说的,分区表,在做 DDL 的时候,影响会更大。如果你使用的是普通分表,那么当你在 truncate 一个分表的时候,肯定不会跟另外一个分表上的查询语句,出现 MDL 锁冲突。
到这里我们小结一下:
- MySQL 在第一次打开分区表的时候,需要访问所有的分区;
- 在 server 层,认为这是同一张表,因此所有分区共用同一个 MDL 锁;
- 在引擎层,认为这是不同的表,因此 MDL 锁之后的执行过程,会根据分区表规则,只访问必要的分区。
而关于“必要的分区”的判断,就是根据 SQL 语句中的 where 条件,结合分区规则来实现的。比如我们上面的例子中,where ftime=‘2018-4-1’,根据分区规则 year 函数算出来的值是 2018,那么就会落在 p_2019 这个分区。
但是,如果这个 where 条件改成 where ftime>=‘2018-4-1’,虽然查询结果相同,但是这时候根据 where 条件,就要访问 p_2019 和 p_others 这两个分区。
如果查询语句的 where 条件中没有分区 key,那就只能访问所有分区了。当然,这并不是分区表的问题。即使是使用业务分表的方式,where 条件中没有使用分表的 key,也必须访问所有的分表。
我们已经理解了分区表的概念,那么什么场景下适合使用分区表呢?
分区表的应用场景
分区表的一个显而易见的优势是对业务透明,相对于用户分表来说,使用分区表的业务代码更简洁。还有,分区表可以很方便的清理历史数据。
如果一项业务跑的时间足够长,往往就会有根据时间删除历史数据的需求。这时候,按照时间分区的分区表,就可以直接通过 alter table t drop partition …这个语法删掉分区,从而删掉过期的历史数据。
这个 alter table t drop partition …操作是直接删除分区文件,效果跟 drop 普通表类似。与使用 delete 语句删除数据相比,优势是速度快、对系统影响小。
小结
这篇文章,我主要和你介绍的是 server 层和引擎层对分区表的处理方式。我希望通过这些介绍,你能够对是否选择使用分区表,有更清晰的想法。
需要注意的是,我是以范围分区(range)为例和你介绍的。实际上,MySQL 还支持 hash 分区、list 分区等分区方法。你可以在需要用到的时候,再翻翻手册。
实际使用时,分区表跟用户分表比起来,有两个绕不开的问题:一个是第一次访问的时候需要访问所有分区,另一个是共用 MDL 锁。
因此,如果要使用分区表,就不要创建太多的分区。我见过一个用户做了按天分区策略,然后预先创建了 10 年的分区。这种情况下,访问分区表的性能自然是不好的。这里有两个问题需要注意:
- 分区并不是越细越好。实际上,单表或者单分区的数据一千万行,只要没有特别大的索引,对于现在的硬件能力来说都已经是小表了。
- 分区也不要提前预留太多,在使用之前预先创建即可。比如,如果是按月分区,每年年底时再把下一年度的 12 个新分区创建上即可。对于没有数据的历史分区,要及时的 drop 掉。
至于分区表的其他问题,比如查询需要跨多个分区取数据,查询性能就会比较慢,基本上就不是分区表本身的问题,而是数据量的问题或者说是使用方式的问题了。
当然,如果你的团队已经维护了成熟的分库分表中间件,用业务分表,对业务开发同学没有额外的复杂性,对 DBA 也更直观,自然是更好的。
问题
我们举例的表中没有用到自增主键,假设现在要创建一个自增字段 id。MySQL 要求分区表中的主键必须包含分区字段。如果要在表 t 的基础上做修改,你会怎么定义这个表的主键呢?为什么这么定义呢?
由于 MySQL 要求主键包含所有的分区字段,所以肯定是要创建联合主键的。
这时候就有两种可选:一种是 (ftime, id),另一种是 (id, ftime)。
如果从利用率上来看,应该使用 (ftime, id) 这种模式。因为用 ftime 做分区 key,说明大多数语句都是包含 ftime 的,使用这种模式,可以利用前缀索引的规则,减少一个索引。
这时的建表语句是:
1 | CREATE TABLE `t` ( |
当然,我的建议是你要尽量使用 InnoDB 引擎。InnoDB 表要求至少有一个索引,以自增字段作为第一个字段,所以需要加一个 id 的单独索引。
1 | CREATE TABLE `t` ( |
当然把字段反过来,创建成:
1 | PRIMARY KEY (`id`,`ftime`), |
也是可以的。
其他问题
[!summary]
一个总结:
1 经典的利用分区表的场景
- zabbix历史数据表的改造,利用存储过程创建和改造
- 后台数据的分析汇总,比如日志数据,便于清理
这两种场景我们都在执行,我们对于分区表在业务采用的是hash 用户ID方式,不过大规模应用分区表的公司我还没遇到过
2 分区表需要注意的几点总结下
1、由于分区表都很大,DDL耗时是非常严重的,必须考虑这个问题
2、分区表不能建立太多的分区,我曾被分享一个因为分区表分区过多导致的主从延迟问题
3、分区表的规则和分区需要预先设置好,否则后来进行修改也很麻烦
[!question]
老师,mysql还有一个参数是innodb_open_files,资料上说作用是限制Innodb能打开的表的数量。它和open_files_limit之间有什么关系吗?
作者回复: 好问题。
在InnoDB引擎打开文件超过 innodb_open_files这个值的时候,就会关掉一些之前打开的文件。
其实我们文章中 ,InnoDB分区表使用了本地分区策略以后,即使分区个数大于open_files_limit ,打开InnoDB分区表也不会报“打开文件过多”这个错误,就是innodb_open_files这个参数发挥的作用。
[!question]
老师,请问add column after column_name跟add column不指定位置,这两种性能上有区别吗?我们在add column 指定after column_name的情况很多。
作者回复: 仅仅看性能,是没什么差别的
但是建议尽量不要加after column_name,也就是说尽量加到最后一列。
因为其实没差别,但是加在最后有以下两个好处:
开始有一些分支支持快速加列,就是说如果你加在最后一列,是瞬间就能完成,而加了after column_name,就用不上这些优化(以后潜在的好处)
我们在前面的文章有提到过,如果怕对线上业务造成影响,有时候是通过“先做备库、切换、再做备库”这种方式来执行ddl的,那么使用after column_name的时候用不上这种方式。
实际上列的数据是不应该有影响的,还是要形成好习惯😆
[!question]
上个问题的跟进:
请教下采用”先做备库、切换、再做备库”DDL方式不支持AFTER COLUMN是因为BINLOG原因吗? 以上DDL方式会存在影响“有损”的吧?“无损”有哪些方案呢?如果备库承载读请求但又不能接受“长时间”延时
作者回复:
1、对,binlog对原因
2、如果延迟算损失,确实是有损的。备库上的读流量要先切换到主库(也就是为什么需要在低峰期做做个操作)
44 | 答疑文章(三):说一说这些好问题
在我看来,能够帮我们扩展一个逻辑的边界的问题,就是好问题。因为通过解决这样的问题,能够加深我们对这个逻辑的理解,或者帮我们关联到另外一个知识点,进而可以帮助我们建立起自己的知识网络。
在工作中会问好问题,是一个很重要的能力。
join 的写法
在[[实践篇-04 31-38节#35 join语句怎么优化?]]中,我在介绍 join 执行顺序的时候,用的都是 straight_join。@郭健 同学在文后提出了两个问题:
- 如果用 left join 的话,左边的表一定是驱动表吗?
- 如果两个表的 join 包含多个条件的等值匹配,是都要写到 on 里面呢,还是只把一个条件写到 on 里面,其他条件写到 where 部分?
为了同时回答这两个问题,我来构造两个表 a 和 b:
1 | create table a(f1 int, f2 int, index(f1))engine=innodb; |
表 a 和 b 都有两个字段 f1 和 f2,不同的是表 a 的字段 f1 上有索引。然后,我往两个表中都插入了 6 条记录,其中在表 a 和 b 中同时存在的数据有 4 行。
@郭健 同学提到的第二个问题,其实就是下面这两种写法的区别:
1 | select * from a left join b on(a.f1=b.f1) and (a.f2=b.f2); /*Q1*/ |
我把这两条语句分别记为 Q1 和 Q2。
首先,需要说明的是,这两个 left join 语句的语义逻辑并不相同。我们先来看一下它们的执行结果。
可以看到:
- 语句 Q1 返回的数据集是 6 行,表 a 中即使没有满足匹配条件的记录,查询结果中也会返回一行,并将表 b 的各个字段值填成 NULL。
- 语句 Q2 返回的是 4 行。从逻辑上可以这么理解,最后的两行,由于表 b 中没有匹配的字段,结果集里面 b.f2 的值是空,不满足 where 部分的条件判断,因此不能作为结果集的一部分。【其实就是先得到join结果,然后经过where过滤】
接下来,我们看看实际执行这两条语句时,MySQL 是怎么做的。
我们先一起看看语句 Q1 的 explain 结果:
可以看到,这个结果符合我们的预期:
- 驱动表是表 a,被驱动表是表 b;
- 由于表 b 的 f1 字段上没有索引,所以使用的是 Block Nexted Loop Join(简称 BNL) 算法。
看到 BNL 算法,你就应该知道这条语句的执行流程其实是这样的:
- 把表 a 的内容读入 join_buffer 中。因为是 select * ,所以字段 f1 和 f2 都被放入 join_buffer 了。
- 顺序扫描表 b,对于每一行数据,判断 join 条件(也就是 a.f1=b.f1 and a.f2=b.f2) 是否满足,满足条件的记录, 作为结果集的一行返回。如果语句中有 where 子句,需要先判断 where 部分满足条件后,再返回。
- 表 b 扫描完成后,对于没有被匹配的表 a 的行(在这个例子中就是 (1,1)、(2,2) 这两行),把剩余字段补上 NULL,再放入结果集中。
对应的流程图如下:
可以看到,这条语句确实是以表 a 为驱动表,而且从执行效果看,也和使用 straight_join 是一样的。
你可能会想,语句 Q2 的查询结果里面少了最后两行数据,是不是就是把上面流程中的步骤 3 去掉呢?我们还是先看一下语句 Q2 的 expain 结果吧。
这里先和你说一句题外话,专栏马上就结束了,我也和你一起根据 explain 结果“脑补”了很多次一条语句的执行流程了,所以我希望你已经具备了这个能力。今天,我们再一起分析一次 SQL 语句的 explain 结果。
可以看到,这条语句是以表 b 为驱动表的。而如果一条 join 语句的 Extra 字段什么都没写的话,就表示使用的是 Index Nested-Loop Join(简称 NLJ)算法。
因此,语句 Q2 的执行流程是这样的:顺序扫描表 b,每一行用 b.f1 到表 a 中去查,匹配到记录后判断 a.f2=b.f2 是否满足,满足条件的话就作为结果集的一部分返回。
那么,为什么语句 Q1 和 Q2 这两个查询的执行流程会差距这么大呢? 其实,这是因为优化器基于 Q2 这个查询的语义做了优化。
为了理解这个问题,我需要再和你交代一个背景知识点:在 MySQL 里,NULL 跟任何值执行等值判断和不等值判断的结果,都是 NULL。这里包括, select NULL = NULL 的结果,也是返回 NULL。
因此,语句 Q2 里面 where a.f2=b.f2 就表示,查询结果里面不会包含 b.f2 是 NULL 的行,这样这个 left join 的语义就是“找到这两个表里面,f1、f2 对应相同的行。对于表 a 中存在,而表 b 中匹配不到的行,就放弃”。
这样,这条语句虽然用的是 left join,但是语义跟 join 是一致的。
因此,优化器就把这条语句的 left join 改写成了 join,然后因为表 a 的 f1 上有索引,就把表 b 作为驱动表,这样就可以用上 NLJ 算法。在执行 explain 之后,你再执行 show warnings,就能看到这个改写的结果,如图 5 所示。
这个例子说明,即使我们在 SQL 语句中写成 left join,执行过程还是有可能不是从左到右连接的。也就是说,使用 left join 时,左边的表不一定是驱动表。
这样看来,如果需要 left join 的语义,就不能把被驱动表的字段放在 where 条件里面做等值判断或不等值判断,必须都写在 on 里面。 那如果是 join 语句呢?【特别注意】
这时候,我们再看看这两条语句:
1 | select * from a join b on(a.f1=b.f1) and (a.f2=b.f2); /*Q3*/ |
我们再使用一次看 explain 和 show warnings 的方法,看看优化器是怎么做的。
可以看到,这两条语句都被改写成:
1 | select * from a join b where (a.f1=b.f1) and (a.f2=b.f2); |
执行计划自然也是一模一样的。
也就是说,在这种情况下,join 将判断条件是否全部放在 on 部分就没有区别了。
Simple Nested Loop Join 的性能问题
我们知道,join 语句使用不同的算法,对语句的性能影响会很大。
我们在文中说到,虽然 BNL 算法和 Simple Nested Loop Join 算法都是要判断 M*N 次(M 和 N 分别是 join 的两个表的行数),但是 Simple Nested Loop Join 算法的每轮判断都要走全表扫描,因此性能上 BNL 算法执行起来会快很多。
为了便于说明,我还是先为你简单描述一下这两个算法。
BNL 算法的执行逻辑是:
- 首先,将驱动表的数据全部读入内存 join_buffer 中,这里 join_buffer 是无序数组;
- 然后,顺序遍历被驱动表的所有行,每一行数据都跟 join_buffer 中的数据进行匹配,匹配成功则作为结果集的一部分返回。
Simple Nested Loop Join 算法的执行逻辑是:顺序取出驱动表中的每一行数据,到被驱动表去做全表扫描匹配,匹配成功则作为结果集的一部分返回。
这两位同学的疑问是,Simple Nested Loop Join 算法,其实也是把数据读到内存里,然后按照匹配条件进行判断,为什么性能差距会这么大呢?
解释这个问题,需要用到 MySQL 中索引结构和 Buffer Pool 的相关知识点:
- 在对被驱动表做全表扫描的时候,如果数据没有在 Buffer Pool 中,就需要等待这部分数据从磁盘读入;
从磁盘读入数据到内存中,会影响正常业务的 Buffer Pool 命中率,而且这个算法天然会对被驱动表的数据做多次访问,更容易将这些数据页放到 Buffer Pool 的头部(请参考[[实践篇-04 31-38节#35 join语句怎么优化?]]中的相关内容);【所以最好不要多次重复访问被驱动表的数据】 - 即使被驱动表数据都在内存中,每次查找“下一个记录的操作”,都是类似指针操作。而 join_buffer 中是数组,遍历的成本更低。
这里有一个核心区别,BNL是将小表读入Join_buffer中,一般能够完全读入(不能的话分段),这样只需要遍历一次被驱动表也就是大表(大表一般要涉及到读盘),而且是顺序读。而Simple Nested Loop Join则每次从驱动表中拿出一行去被驱动的大表中全表扫描,要多次全表扫被驱动的大表,且是随机读。
所以说,BNL 算法的性能会更好。
distinct 和 group by 的性能
在第 37 篇文章《什么时候会使用内部临时表?》中,@老杨同志 提了一个好问题:如果只需要去重,不需要执行聚合函数,distinct 和 group by 哪种效率高一些呢?
我来展开一下他的问题:如果表 t 的字段 a 上没有索引,那么下面这两条语句:
1 | select a from t group by a order by null; |
的性能是不是相同的?
首先需要说明的是,这种 group by 的写法,并不是 SQL 标准的写法。标准的 group by 语句,是需要在 select 部分加一个聚合函数,比如:
1 | select a,count(*) from t group by a order by null; |
这条语句的逻辑是:按照字段 a 分组,计算每组的 a 出现的次数。在这个结果里,由于做的是聚合计算,相同的 a 只出现一次。
备注:这里你可以顺便复习一下[[实践篇-04 31-38节#37 什么时候会使用内部临时表?]]中关于 group by 的相关内容。
没有了 count(*) 以后,也就是不再需要执行“计算总数”的逻辑时,第一条语句的逻辑就变成是:按照字段 a 做分组,相同的 a 的值只返回一行。而这就是 distinct 的语义,所以不需要执行聚合函数时,distinct 和 group by 这两条语句的语义和执行流程是相同的,因此执行性能也相同。
这两条语句的执行流程是下面这样的。【使用临时表去重】
- 创建一个临时表,临时表有一个字段 a,并且在这个字段 a 上创建一个唯一索引;
- 遍历表 t,依次取数据插入临时表中:
- 如果发现唯一键冲突,就跳过;
- 否则插入成功;
- 遍历完成后,将临时表作为结果集返回给客户端。
备库自增主键问题
除了性能问题,大家对细节的追问也很到位。在第 39 篇文章《自增主键为什么不是连续的?》评论区,@帽子掉了 同学问到:在 binlog_format=statement 时,语句 A 先获取 id=1,然后语句 B 获取 id=2;接着语句 B 提交,写 binlog,然后语句 A 再写 binlog。这时候,如果 binlog 重放,是不是会发生语句 B 的 id 为 1,而语句 A 的 id 为 2 的不一致情况呢?
首先,这个问题默认了“自增 id 的生成顺序,和 binlog 的写入顺序可能是不同的”,这个理解是正确的。
其次,这个问题限定在 statement 格式下,也是对的。因为 row 格式的 binlog 就没有这个问题了,Write row event 里面直接写了每一行的所有字段的值。
而至于为什么不会发生不一致的情况,我们来看一下下面的这个例子。
1 | create table t(id int auto_increment primary key); |
可以看到,在 insert 语句之前,还有一句 SET INSERT_ID=1。这条命令的意思是,这个线程里下一次需要用到自增值的时候,不论当前表的自增值是多少,固定用 1 这个值。
这个 SET INSERT_ID 语句是固定跟在 insert 语句之前的,比如 @帽子掉了同学提到的场景,主库上语句 A 的 id 是 1,语句 B 的 id 是 2,但是写入 binlog 的顺序先 B 后 A,那么 binlog 就变成:
1 | SET INSERT_ID=2; |
你看,在备库上语句 B 用到的 INSERT_ID 依然是 2,跟主库相同。
因此,即使两个 INSERT 语句在主备库的执行顺序不同,自增主键字段的值也不会不一致。
问题
在第 8 篇文章的评论区, @XD 同学提到一个问题:他查看了一下 innodb_trx,发现这个事务的 trx_id 是一个很大的数(281479535353408),而且似乎在同一个 session 中启动的会话得到的 trx_id 是保持不变的。当执行任何加写锁的语句后,trx_id 都会变成一个很小的数字(118378)。
你可以通过实验验证一下,然后分析看看,事务 id 的分配规则是什么,以及 MySQL 为什么要这么设计呢?
其他问题
[!question]
老师,BNl算法,如果where条件中有驱动表的过滤条件,也不会在join时候全部载入内存吧?
作者回复: 对,驱动表现过滤,然后进join buffer
[!question]
老师,关于备库自增id我有一个问题。既然binlog不管是statement模式还是row模式,里面的insert语句跟着的自增id都是固定的。那假如发生主备切换,备库变成主库后,客户端往新主库里插入数据时,自增id的起始值是多少,有没有可能跟已有的记录id冲突?尤其是备库还没有处理完同步过来的binlog就开始接受客户端请求时。如果要求备库必须处理完binlog才能接受客户端请求,那么怎么保证主备切换的过程中,不影响用户使用。谢谢。
作者回复: “自增id的起始值是多少,有没有可能跟已有的记录id冲突?”
如果没有主备延迟就不会出现;
“尤其是备库还没有处理完同步过来的binlog就开始接受客户端请求时。” , 对,这种情况就会。
“如果要求备库必须处理完binlog才能接受客户端请求,那么怎么保证主备切换的过程中,不影响用户使用” 一般都是有这个要求的。要尽量减少影响,就是控制主备延迟。
[!question]
b+树索引结构的层次和表数据量的关系是怎么样的?也就是说15万的数据量是三层结构?达到多少数据量是四层次? 一般在线服务中一个表的数据量一般多大合适?
作者回复: 你可以这么理解, N层放不下的时候,就增加一层来放。
这个行为是由页分裂触发的
在线服务最好不要让索引树超过4层
45 | 自增id用完怎么办?
MySQL 里有很多自增的 id,每个自增 id 都是定义了初始值,然后不停地往上加步长。虽然自然数是没有上限的,但是在计算机里,只要定义了表示这个数的字节长度,那它就有上限。比如,无符号整型 (unsigned int) 是 4 个字节,上限就是 232-1。
既然自增 id 有上限,就有可能被用完。但是,自增 id 用完了会怎么样呢?
今天这篇文章,我们就来看看 MySQL 里面的几种自增 id,一起分析一下它们的值达到上限以后,会出现什么情况。
表定义自增值 id
说到自增 id,你第一个想到的应该就是表结构定义里的自增字段,也就是在[[实践篇-05 39-45节#39 自增主键为什么不是连续的?]]中和你介绍过的自增主键 id。
表定义的自增值达到上限后的逻辑是:再申请下一个 id 时,得到的值保持不变。
我们可以通过下面这个语句序列验证一下:
1 | create table t(id int unsigned auto_increment primary key) auto_increment=4294967295; |
可以看到,第一个 insert 语句插入数据成功后,这个表的 AUTO_INCREMENT 没有改变(还是 4294967295),就导致了第二个 insert 语句又拿到相同的自增 id 值,再试图执行插入语句,报主键冲突错误。
232-1(4294967295)不是一个特别大的数,对于一个频繁插入删除数据的表来说,是可能会被用完的。因此在建表的时候你需要考察你的表是否有可能达到这个上限,如果有可能,就应该创建成 8 个字节的 bigint unsigned。
InnoDB 系统自增 row_id
如果你创建的 InnoDB 表没有指定主键,那么 InnoDB 会给你创建一个不可见的,长度为 6 个字节的 row_id。InnoDB 维护了一个全局的 dict_sys.row_id 值,所有无主键的 InnoDB 表,每插入一行数据,都将当前的 dict_sys.row_id 值作为要插入数据的 row_id,然后把 dict_sys.row_id 的值加 1。
实际上,在代码实现时 row_id 是一个长度为 8 字节的无符号长整型 (bigint unsigned)。但是,InnoDB 在设计时,给 row_id 留的只是 6 个字节的长度,这样写到数据表中时只放了最后 6 个字节,所以 row_id 能写到数据表中的值,就有两个特征:
- row_id 写入表中的值范围,是从 0 到 2^48-1;
- 当 dict_sys.row_id=2^48时,如果再有插入数据的行为要来申请 row_id,拿到以后再取最后 6 个字节的话就是 0。
也就是说,写入表的 row_id 是从 0 开始到 2^48-1。达到上限后,下一个值就是 0,然后继续循环。
当然,2^48-1 这个值本身已经很大了,但是如果一个 MySQL 实例跑得足够久的话,还是可能达到这个上限的。在 InnoDB 逻辑里,申请到 row_id=N 后,就将这行数据写入表中;如果表中已经存在 row_id=N 的行,新写入的行就会覆盖原有的行。
要验证这个结论的话,你可以通过 gdb 修改系统的自增 row_id 来实现。注意,用 gdb 改变量这个操作是为了便于我们复现问题,只能在测试环境使用。
可以看到,在我用 gdb 将 dict_sys.row_id 设置为 248之后,再插入的 a=2 的行会出现在表 t 的第一行,因为这个值的 row_id=0。之后再插入的 a=3 的行,由于 row_id=1,就覆盖了之前 a=1 的行,因为 a=1 这一行的 row_id 也是 1。
从这个角度看,我们还是应该在 InnoDB 表中主动创建自增主键。因为,表自增 id 到达上限后,再插入数据时报主键冲突错误,是更能被接受的。
毕竟覆盖数据,就意味着数据丢失,影响的是数据可靠性;报主键冲突,是插入失败,影响的是可用性。而一般情况下,可靠性优先于可用性。
Xid
在第 15 篇文章《答疑文章(一):日志和索引相关问题》中,我和你介绍 redo log 和 binlog 相配合的时候,提到了它们有一个共同的字段叫作 Xid。它在 MySQL 中是用来对应事务的。
那么,Xid 在 MySQL 内部是怎么生成的呢?
MySQL 内部维护了一个全局变量 global_query_id,每次执行语句的时候将它赋值给 Query_id,然后给这个变量加 1。如果当前语句是这个事务执行的第一条语句,那么 MySQL 还会同时把 Query_id 赋值给这个事务的 Xid。
而 global_query_id 是一个纯内存变量,重启之后就清零了。所以你就知道了,在同一个数据库实例中,不同事务的 Xid 也是有可能相同的。
但是 MySQL 重启之后会重新生成新的 binlog 文件,这就保证了,同一个 binlog 文件里,Xid 一定是惟一的。
虽然 MySQL 重启不会导致同一个 binlog 里面出现两个相同的 Xid,但是如果 global_query_id 达到上限后,就会继续从 0 开始计数。从理论上讲,还是就会出现同一个 binlog 里面出现相同 Xid 的场景。
因为 global_query_id 定义的长度是 8 个字节,这个自增值的上限是 2^64-1。要出现这种情况,必须是下面这样的过程:
- 执行一个事务,假设 Xid 是 A;
- 接下来执行 2^64次查询语句,让 global_query_id 回到 A;
- 再启动一个事务,这个事务的 Xid 也是 A。
不过,2^64这个值太大了,大到你可以认为这个可能性只会存在于理论上。
Innodb trx_id
Xid 和 InnoDB 的 trx_id 是两个容易混淆的概念。
Xid 是由 server 层维护的。InnoDB 内部使用 Xid,就是为了能够在 InnoDB 事务和 server 之间做关联。但是,InnoDB 自己的 trx_id,是另外维护的。
其实,你应该非常熟悉这个 trx_id。它就是在我们在[[基础篇 1-8节#08 事务到底是隔离的还是不隔离的?]]中讲事务可见性时,用到的事务 id(transaction id)。
InnoDB 内部维护了一个 max_trx_id 全局变量,每次需要申请一个新的 trx_id 时,就获得 max_trx_id 的当前值,然后并将 max_trx_id 加 1。
InnoDB 数据可见性的核心思想是:每一行数据都记录了更新它的 trx_id,当一个事务读到一行数据的时候,判断这个数据是否可见的方法,就是通过事务的一致性视图与这行数据的 trx_id 做对比。
对于正在执行的事务,你可以从 information_schema.innodb_trx 表中看到事务的 trx_id。
我在上一篇文章的末尾留给你的思考题,就是关于从 innodb_trx 表里面查到的 trx_id 的。现在,我们一起来看一个事务现场:
session B 里,我从 innodb_trx 表里查出的这两个字段,第二个字段 trx_mysql_thread_id 就是线程 id。显示线程 id,是为了说明这两次查询看到的事务对应的线程 id 都是 5,也就是 session A 所在的线程。
可以看到,T2 时刻显示的 trx_id 是一个很大的数;T4 时刻显示的 trx_id 是 1289,看上去是一个比较正常的数字。这是什么原因呢?
实际上,在 T1 时刻,session A 还没有涉及到更新,是一个只读事务。而对于只读事务,InnoDB 并不会分配 trx_id。也就是说:
- 在 T1 时刻,trx_id 的值其实就是 0。而这个很大的数,只是显示用的。一会儿我会再和你说说这个数据的生成逻辑。
- 直到 session A 在 T3 时刻执行 insert 语句的时候,InnoDB 才真正分配了 trx_id。所以,T4 时刻,session B 查到的这个 trx_id 的值就是 1289。
需要注意的是,除了显而易见的修改类语句外,如果在 select 语句后面加上 for update,这个事务也不是只读事务。
在上一篇文章的评论区,有同学提出,实验的时候发现不止加 1。这是因为:
- update 和 delete 语句除了事务本身,还涉及到标记删除旧数据,也就是要把数据放到 purge 队列里等待后续物理删除,这个操作也会把 max_trx_id+1, 因此在一个事务中至少加 2;
- InnoDB 的后台操作,比如表的索引信息统计这类操作,也是会启动内部事务的,因此你可能看到,trx_id 值并不是按照加 1 递增的。
那么,T2 时刻查到的这个很大的数字是怎么来的呢?
其实,这个数字是每次查询的时候由系统临时计算出来的。它的算法是:把当前事务的 trx 变量的指针地址转成整数,再加上 2^48。使用这个算法,就可以保证以下两点:
- 因为同一个只读事务在执行期间,它的指针地址是不会变的,所以不论是在 innodb_trx 还是在 innodb_locks 表里,同一个只读事务查出来的 trx_id 就会是一样的。
- 如果有并行的多个只读事务,每个事务的 trx 变量的指针地址肯定不同。这样,不同的并发只读事务,查出来的 trx_id 就是不同的。
那么,为什么还要再加上 2^48呢?
在显示值里面加上 2^48,目的是要保证只读事务显示的 trx_id 值比较大,正常情况下就会区别于读写事务的 id。但是,trx_id 跟 row_id 的逻辑类似,定义长度也是 8 个字节。因此,在理论上还是可能出现一个读写事务与一个只读事务显示的 trx_id 相同的情况。不过这个概率很低,并且也没有什么实质危害,可以不管它。
另一个问题是,只读事务不分配 trx_id,有什么好处呢?
- 一个好处是,这样做可以减小事务视图里面活跃事务数组的大小。因为当前正在运行的只读事务,是不影响数据的可见性判断的。所以,在创建事务的一致性视图时,InnoDB 就只需要拷贝读写事务的 trx_id。
- 另一个好处是,可以减少 trx_id 的申请次数。在 InnoDB 里,即使你只是执行一个普通的 select 语句,在执行过程中,也是要对应一个只读事务的。所以只读事务优化后,普通的查询语句不需要申请 trx_id,就大大减少了并发事务申请 trx_id 的锁冲突。
由于只读事务不分配 trx_id,一个自然而然的结果就是 trx_id 的增加速度变慢了。
但是,max_trx_id 会持久化存储,重启也不会重置为 0,那么从理论上讲,只要一个 MySQL 服务跑得足够久,就可能出现 max_trx_id 达到 2^48-1 的上限,然后从 0 开始的情况。
当达到这个状态后,MySQL 就会持续出现一个脏读的 bug,我们来复现一下这个 bug。
首先我们需要把当前的 max_trx_id 先修改成 248-1。注意:这个 case 里使用的是可重复读隔离级别。具体的操作流程如下:
由于我们已经把系统的 max_trx_id 设置成了 248-1,所以在 session A 启动的事务 TA 的低水位就是 248-1。
在 T2 时刻,session B 执行第一条 update 语句的事务 id 就是 248-1,而第二条 update 语句的事务 id 就是 0 了,这条 update 语句执行后生成的数据版本上的 trx_id 就是 0。
在 T3 时刻,session A 执行 select 语句的时候,判断可见性发现,c=3 这个数据版本的 trx_id,小于事务 TA 的低水位,因此认为这个数据可见。
但,这个是脏读。
由于低水位值会持续增加,而事务 id 从 0 开始计数,就导致了系统在这个时刻之后,所有的查询都会出现脏读的。
并且,MySQL 重启时 max_trx_id 也不会清 0,也就是说重启 MySQL,这个 bug 仍然存在。
那么,这个 bug 也是只存在于理论上吗?
假设一个 MySQL 实例的 TPS 是每秒 50 万,持续这个压力的话,在 17.8 年后,就会出现这个情况。如果 TPS 更高,这个年限自然也就更短了。但是,从 MySQL 的真正开始流行到现在,恐怕都还没有实例跑到过这个上限。不过,这个 bug 是只要 MySQL 实例服务时间够长,就会必然出现的。
当然,这个例子更现实的意义是,可以加深我们对低水位和数据可见性的理解。你也可以借此机会再回顾下[[基础篇 1-8节#08 事务到底是隔离的还是不隔离的?]]中的相关内容。
thread_id
接下来,我们再看看线程 id(thread_id)。其实,线程 id 才是 MySQL 中最常见的一种自增 id。平时我们在查各种现场的时候,show processlist 里面的第一列,就是 thread_id。
thread_id 的逻辑很好理解:系统保存了一个全局变量 thread_id_counter,每新建一个连接,就将 thread_id_counter 赋值给这个新连接的线程变量。
thread_id_counter 定义的大小是 4 个字节,因此达到 2^32-1 后,它就会重置为 0,然后继续增加。但是,你不会在 show processlist 里看到两个相同的 thread_id。
这,是因为 MySQL 设计了一个唯一数组的逻辑,给新线程分配 thread_id 的时候,逻辑代码是这样的:
1 | do { |
这个代码逻辑简单而且实现优雅,相信你一看就能明白。【插入失败则继续自增,判断下一个ID】
小结
今天这篇文章,我给你介绍了 MySQL 不同的自增 id 达到上限以后的行为。数据库系统作为一个可能需要 7*24 小时全年无休的服务,考虑这些边界是非常有必要的。
[!abstract]
每种自增 id 有各自的应用场景,在达到上限后的表现也不同:
- 表的自增 id 达到上限后,再申请时它的值就不会改变,进而导致继续插入数据时报主键冲突的错误。
- row_id 达到上限后,则会归 0 再重新递增,如果出现相同的 row_id,后写的数据会覆盖之前的数据。
- Xid 只需要不在同一个 binlog 文件中出现重复值即可。虽然理论上会出现重复值,但是概率极小,可以忽略不计。
- InnoDB 的 max_trx_id 递增值每次 MySQL 重启都会被保存起来,所以我们文章中提到的脏读的例子就是一个必现的 bug,好在留给我们的时间还很充裕。
- thread_id 是我们使用中最常见的,而且也是处理得最好的一个自增 id 逻辑了。
当然,在 MySQL 里还有别的自增 id,比如 table_id、binlog 文件序号等,就留给你去验证和探索了。
不同的自增 id 有不同的上限值,上限值的大小取决于声明的类型长度。而我们专栏声明的上限 id 就是 45,所以今天这篇文章也是我们的最后一篇技术文章了。
我的MySQL心路历程
这里,我先和你说一下,在这个直播中,我主要分享的内容:
- 我和 MySQL 打交道的经历;
- 你为什么要了解数据库原理;
- 我建议的 MySQL 学习路径;
- DBA 的修炼之道。
我的经历
以丰富的经历进入百度
我是福州大学毕业的,据我了解,那时候我们学校的应届生很难直接进入百度,都要考到浙江大学读个研究生才行。没想到的是,我投递了简历后居然进了面试。
入职以后,我跑去问当时的面试官,为什么我的简历可以通过筛选?他们说:“因为你的简历厚啊”。我在读书的时候,确实做了很多项目,也实习过不少公司,所以简历里面的经历就显得很丰富了。
在面试的时候,有个让我印象很深刻的事儿。面试官问我说,你有这么多实习经历,有没有什么比较好玩儿的事?我想了想答道,跟你说个数据量很大的事儿 ,在跟移动做日志分析的时候我碰到了几千万行的数据。他听完以后就笑了。
后来,我进了百度才知道,几千万行那都是小数据。
开始尝试看源码解决问题
加入百度后,我是在贴吧做后端程序,比如权限系统等等。其实很简单,就是写一个 C 语言程序,响应客户端请求,然后返回结果。
那个时候,我还仅仅是个 MySQL 的普通用户,使用了一段时间后就出现问题了:一个跑得很快的请求,偶尔会又跑得非常慢。老板问这是什么原因,而我又不好意思说不知道,于是就自己上网查资料。
但是,2008 年那会儿,网上资料很少,花了挺长时间也没查出个所以然。最终,我只好去看源码。翻到源码,我当时就觉得它还蛮有意思的。而且,源码真的可以帮我解决一些问题。
于是一发不可收拾,我从那时候就入了源码的“坑”。
混社区分享经验
2010 年的时候,阿里正好在招数据库的开发人员。虽然那时我还只是看得懂源码,没有什么开发经验,但还是抱着试试看的态度投了简历。然后顺利通过了面试,成功进入了阿里。之后,我就跟着褚霸(霸爷)干了 7 年多才离开了阿里。
在百度的时候,我基本上没有参加过社区活动。因为那时候百度可能更提倡内部分享,解决问题的经验基本上都是在内网分享。所以,去了阿里以后,我才建了博客、开了微博。我在阿里的花名叫丁奇,博客、微博、社区也因此都是用的这个名字。
为什么要了解数据库原理?
这里,我讲几个亲身经历的事情,和你聊聊为什么要了解数据库原理。
了解原理能帮你更好地定位问题
一次同学聚会,大家谈起了技术问题。一个在政府里的同学说,他们的系统很奇怪,每天早上都得重启一下应用程序,否则就提示连接数据库失败,他们都不知道该怎么办。
我分析说,按照这个错误提示,应该就是连接时间过长了,断开了连接。数据库默认的超时时间是 8 小时,而你们平时六点下班,下班之后系统就没有人用了,等到第二天早上九点甚至十点才上班,这中间的时间已经超过 10 个小时了,数据库的连接肯定就会断开了。
我当时说,估计这个系统程序写得比较差,连接失败也不会重连,仍然用原来断掉的连接,所以就报错了。然后,我让他回去把超时时间改得长一点。后来他跟我说,按照这个方法,问题已经解决了。
由此,我也更深刻地体会到,作为开发人员,即使我们只知道每个参数的意思,可能就可以给出一些问题的正确应对方法。
了解原理能让你更巧妙地解决问题
我在做贴吧系统的时候,每次访问页面都要请求一次权限。所以,这个请求权限的请求,访问概率会非常高,不可能每次都去数据库里查,怎么办呢?
我想了个简单的方案:在应用程序里面开了个很大的内存,启动的时候就把整张表全部 load 到内存里去。这样再有权限请求的时候,直接从内存里取就行了。
数据库重启时,我的进程也会跟着重启,接下来就会到数据表里面做全表扫描,把整个用户相关信息全部塞到内存里面去。
但是,后来我遇到了一个很郁闷的情况。有时候 MySQL 崩溃了,我的程序重新加载权限到内存里,结果这个 select 语句要执行 30 分钟左右。本来 MySQL 正常重启一下是很快的,进程重启也很快,正常加载权限的过程只需要两分钟就跑完了。但是,为什么异常重启的时候就要 30 分钟呢?
我没辙了,只好去看源码。然后,我发现 MySQL 有个机制,当它觉得系统空闲时会尽量去刷脏页。
具体到我们的例子里,MySQL 重启以后,会执行我的进程做全表扫描,但是因为这个时候权限数据还没有初始化完成,我的 Server 层不可能提供服务,于是 MySQL 里面就只有我那一个 select 全表扫描的请求,MySQL 就认为现在很闲,开始拼命地刷脏页,结果就吃掉了大量的磁盘资源,导致我的全表扫描也跑得很慢。
知道了这个机制以后,我就写了个脚本,每隔 0.5 秒发一个请求,执行一个简单的 SQL 查询,告诉数据库其实我现在很忙,脏页刷得慢一点。
脚本一发布使用,脏页果然刷得慢了,加载权限的扫描也跑得很快了。据说我离职两年多以后,这个脚本还在用。
你看,如果我们懂得一些参数,并可以理解这些参数,就可以做正确的设置了。而如果我们进一步地懂得一些原理,就可以更巧妙地解决问题了。
看得懂源码让你有更多的方法
2012 年的时候,阿里双十一业务的压力比较大。当时还没有这么多的 SSD,是机械硬盘的时代。
为了应对压力我们开始引入 SSD,但是不敢把 SSD 直接当存储用,而是作为二级缓存。当时,我们用了一个叫作 Flashcache 的开源系统(现在已经是老古董级别了,不知道你有没有听过这个系统)。
Flashcache 实现,把 SSD 当作物理盘的二级缓存,可以提升性能。但是,我们自己部署后发现性能提升的效果没有预想的那么好,甚至还不如纯机械盘。
于是,我跟霸爷就开始研究。霸爷负责分析 Flashcache 的源码,我负责分析 MySQL 源码。后来我们发现 Flashcache 是有脏页比例的,当脏页比例到了 80% 就会停下来强行刷盘。
一开始我们以为这个脏页比例是全部的 20%,看了源码才知道,原来它分了很多个桶,比如说一个桶 20M,这个桶如果用完 80%,它就认为脏页满了,就开始刷脏页。这也就意味着,如果你是顺序写的话,很容易就会把一个桶写满。
知道了这个原理以后,我就把日志之类顺序写的数据全都放到了机械硬盘,把随机写的数据放到了 Flashcache 上。这样修改以后,效果就好了。
你看,如果能看得懂源码,你的操作行为就会不一样。
MySQL 学习路径
说到 MySQL 的学习路径,其实我上面分享的这些内容,都可以归结为学习路径。
首先你要会用,要去了解每个参数的意义,这样你的运维行为(使用行为)就会不一样。千万不要从网上拿了一些使用建议,别人怎么用,你就怎么用,而不去想为什么。再往后,就要去了解每个参数的实现原理。一旦你了解了这些原理,你的操作行为就会不一样。 再进一步,如果看得懂源码,那么你对数据库的理解也会不一样。
再来讲讲我是怎么带应届生的。实践是很好的学习方式,所以我会让新人来了以后先搭主备,然后你就会发现每个人的自学能力都不一样。比如遇到有延迟,或者我们故意构造一个主备数据不一致的场景,让新人了解怎么分析问题,解决问题。
如果一定要总结出一条学习路径的话,那首先要会用,然后可以发现问题。
在专栏里面,我在每篇文章末尾,都会提出一个常见问题,作为思考题。这些问题都不会很难,是跟专栏文章挂钩、又是会经常遇到的,但又无法直接从文章里拿到答案。
我的建议是,你可以尝试先不看答案自己去思考,或者去数据库里面翻一翻,这将会是一个不错的过程。
再下一步就是实践。之后当你觉得开始有一些“线”的概念了,再去看 MySQL 的官方手册。在我的专栏里,有人曾问我要不要直接去看手册?
我的建议是,一开始千万不要着急看手册,这里面有 100 多万个英文单词,你就算再厉害,也是看了后面忘了前面。所以,你一定要自己先有脉络,然后有一个知识网络,再看手册去查漏补缺。
我自己就是这么一路走过来的。
另外,在专栏的留言区,很多用户都希望我能推荐一本书搭配专栏学习。如果只推荐一本的话,我建议你读一下《高性能 MySQL》这本书,它是 MySQL 这个领域的经典图书,已经出到第三版了,你可以想象一下它的流行度。
这本书的其中两位译者(彭立勋、翟卫祥)是我原团队的小伙伴,有着非常丰富的 MySQL 源码开发经验,他们对 MySQL 的深刻理解,让这本书保持了跟原作英文版同样高的质量。
DBA 的修炼
DBA 和开发工程师有什么相同点?
我带过开发团队,也带过 DBA 团队,所以可以分享一下这两个岗位的交集。
其实,DBA 本身要有些开发底子,比如说做运维系统的开发。另外,自动化程度越高,DBA 的日常运维工作量就越少,DBA 得去了解开发业务逻辑,往业务架构师这个方向去做。
开发工程师也是一样,不能所有的问题都指望 DBA 来解决。因为,DBA 在每个公司都是很少的几个人。所以,开发也需要对数据库原理有一定的了解,这样向 DBA 请教问题时才能更专业,更高效地解决问题。
所以说,这两个岗位应该有一定程度的融合,即:开发要了解数据库原理,DBA 要了解业务和开发。
DBA 有前途吗?
这里我要强调的是,每个岗位都有前途,只需要根据时代变迁稍微调整一下方向。
像原来开玩笑说 DBA 要体力好,因为得搬服务器。后来 DBA 的核心技能成了会搭库、会主备切换,但是现在这些也不够用了,因为已经有了自动化系统。
所以,DBA 接下来一方面是要了解业务,做业务的架构师;另一方面,是要有前瞻性,做主动诊断系统,把每个业务的问题挑出来做成月报,让业务开发去优化,有不清楚的地方,开发同学会来找你咨询。你帮助他们做好了优化之后,可以把优化的指标呈现出来。这将很好地体现出你对于公司的价值。
有哪些比较好的习惯和提高 SQL 效率的方法?
这个方法,总结起来就是:要多写 SQL,培养自己对 SQL 语句执行效率的感觉。以后再写或者建索引的时候,知道这个语句执行下去大概的时间复杂度,是全表扫描还是索引扫描、是不是需要回表,在心里都有一个大概的概念。
这样每次写出来的 SQL 都会快一点,而且不容易犯低级错误。这也正式我开设这个专栏的目标。
看源码需要什么技术?
看源码的话,一是要掌握 C 和 C++;另外还要熟悉一些调试工具。因为代码是静态的,运行起来是动态的,看代码是单线程的,运行起来是多线程的,所以要会调试。
另外,我不建议你用可视化的工具。虽然可视化工具很方便,但你不知道这个操作点下去以后,实际上做了什么,所以我建议你自己手写代码和 SQL 语句,这样对一些底层原理你会更明白。
怎么学习 C、C++?
我在读研究生的时候,在 C 和 C++ 语言的学习上进步最大。
那时,我去给专科上 C 和 C++ 的课。我觉得自己已经会了,完全可以教得了。但去了之后,我才知道,自己会跟能够教别人完全是两码事儿。备课的时候,你不能只讲会用的部分,还得把原理讲清楚。这样,就会倒逼自己进行更深入更全面的学习。
有的人看完技术博客和专栏,会把这篇文章的提纲列一下,写写自己的问题和对这篇文章的理解。这个过程,是非常利于学习的。因为你听进来是一回事儿,讲出去则是另一回事儿。
学数据库要保持什么心态?
不只是数据库,所有多线程的服务,调试和追查问题的过程都是很枯燥的,遇到问题都会很麻烦。但是,你找出问题时的那一下会很爽。
我觉得你得找到这种感觉,它可以支持你度过接下来要枯燥很久的那段时光,这样你才能继续坚持下去。
当然,如果有更快乐的学习过程还是更好的,希望这个专栏能让你学习得轻松些。
结束语 | 点线网面,一起构建MySQL知识网络
有同学在问 MySQL 的学习路径,我在这里就和你谈谈我的理解。
1. 路径千万条,实践第一条
如果你问一个 DBA“理解得最深刻的知识点”,他很可能告诉你是他踩得最深的那个坑。由此,“实践”的重要性可见一斑。
以前我带新人的时候,第一步就是要求他们手动搭建一套主备复制结构。并且,平时碰到问题的时候,我要求要动手复现。
从专栏评论区的留言可以看出来,有不少同学在跟着专栏中的案例做实验,我觉得这是个非常好的习惯,希望你能继续坚持下去。在阅读其他技术文章、图书的时候,也是同样的道理。如果你觉得自己理解了一个知识点,也一定要尝试设计一个例子来验证它。
同时,在设计案例的时候,我建议你也设计一个对照的反例,从而达到知识融汇贯通的目的。就像我在写这个专栏的过程中,就感觉自己也涨了不少知识,主要就得益于给文章设计案例的过程。
2. 原理说不清,双手白费劲
不论是先实践再搞清楚原理去解释,还是先明白原理再通过实践去验证,都不失为一种好的学习方法,因人而异。但是,怎么证明自己是不是真的把原理弄清楚了呢?答案是说出来、写出来。
如果有人请教你某个知识点,那真是太好了,一定要跟他讲明白。不要觉得这是在浪费时间。因为这样做,一来可以帮你验证自己确实搞懂了这个知识点;二来可以提升自己的技术表达能力,毕竟你终究要面临和这样的三类人讲清楚原理的情况,即:老板、晋升答辩的评委、新工作的面试官。
我在带新人的时候,如果这一届的新人不止一个,就会让他们组成学习小组,并定期给他们出一个已经有确定答案的问题。大家分头去研究,之后在小组内进行讨论。如果你能碰到愿意跟你结成学习小组的同学,一定要好好珍惜。
而“写出来”又是一个更高的境界。因为,你在写的过程中,就会发现这个“明白”很可能只是一个假象。所以,在专栏下面写下自己对本章知识点的理解,也是一个不错的夯实学习成果的方法。
3. 知识没体系,转身就忘记
把知识点“写下来”,还有一个好处,就是你会发现这个知识点的关联知识点。深究下去,点就连成线,然后再跟别的线找交叉。
比如,我们专栏里面讲到对临时表的操作不记录日志,然后你就可以给自己一个问题,这会不会导致备库同步出错?再比如,了解了临时表在不同的 binlog 格式下的行为,再追问一句,如果创建表的时候是 statement 格式,之后再修改为 row 格式(或者反之),会怎么样呢?
把这些都搞明白以后,你就能够把临时表、日志格式、同步机制,甚至于事务机制都连起来了。
相信你和我一样,在学习过程中最喜欢的就是这种交叉的瞬间。交叉多了,就形成了网络。而有了网络以后,吸收新知识的速度就很快了。
比如,如果你对事务隔离级别弄得很清楚了,在看到第 45 篇文章讲的 max_trx_id 超限会导致持续脏读的时候,相信你理解起来就很容易了。
4. 手册补全面,案例扫盲点
有同学还问我,要不要一开始就看手册?我的建议是不要。看手册的时机,应该是你的知识网络构建得差不多的时候。
那你可能会问,什么时候算是差不多呢?其实,这没有一个固定的标准。但是,有一些基本实践可以帮你去做一个检验。
- 能否解释清楚错误日志(error log)、慢查询日志(slow log)中每一行的意思?
- 能否快速评估出一个表结构或者一条 SQL 语句,设计得是否合理?
- 能否通过 explain 的结果,来“脑补”整个执行过程(我们已经在专栏中练习几次了)?
- 到网络上找 MySQL 的实践建议,对于每一条做一次分析:
- 如果觉得不合理,能否给出自己的意见?
- 如果觉得合理,能否给出自己的解释?
那,怎么判断自己的意见或者解释对不对呢?最快速、有效的途径,就是找有经验的人讨论。比如说,留言到我们专栏的相关文章的评论区,就是一个可行的方法。
这些实践做完后,你就应该对自己比较有信心了。这时候,你可以再去看手册,把知识网络中的盲点补全,进而形成面。而补全的方法就是前两点了,理论加实践。
我希望这 45 篇文章,可以在你构建 MySQL 知识体系的过程中,起到一个加速器的作用。
我特意安排在最后一篇文章,和你介绍 MySQL 里各种自增 id 达到定义的上限以后的不同行为。“45”就是我们这个专栏的 id 上限,而这一篇结束语,便是超过上限后的第一个值。这是一个未定义的值,由你来定义:
- 有的同学可能会像表定义的自增 id 一样,就让它定格在这里;
- 有的同学可能会像 row_id 一样,二刷,然后用新的、更全面的理解去替代之前的理解;
- 也许最多的情况是会像 thread_id 一样,将已经彻底掌握的文章标记起来,专门刷那些之前看过、但是已经印象模糊的文章。
不论是哪一种策略,只要这 45 篇文章中,有那么几个知识点,像 Xid 或者 InnoDB trx_id 一样,持久化到了你的知识网络里,你和我在这里花费的时间,就是“极客”的时间,就值了。